Fix repeat ownership and close out capture evidence

Start each repeat after its own manifest entry, refuse packets from a restarted
or stopped camera, keep a simulated repeat from reading as recorded evidence,
and decide completeness from the writer report. Add the operator summary (four
files plus an atomic pointer) and read-only video review of a hold window.

Verified with the full Autocal suite (567 passed), the shared recorder/playback
files (57 passed), the lint gate and the documentation gate; the two pre-existing
panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
ykcheungab
2026-10-17 10:44:30 +08:00
parent d7540f52ec
commit 19426c3c4a
32 changed files with 5461 additions and 191 deletions
+50 -2
View File
@@ -392,7 +392,22 @@
"col_levels": "Levels (N)", "col_levels": "Levels (N)",
"col_hold": "Hold (s)", "col_hold": "Hold (s)",
"col_repeats": "Repeats", "col_repeats": "Repeats",
"col_done": "Done" "col_done": "Done",
"capture": "Recording: {status} · {frames} frames · {dropped} dropped · {reason}",
"capture_idle": "Not ready",
"capture_recording": "Recording",
"capture_closing": "Closing",
"capture_complete": "Complete",
"capture_incomplete": "Evidence incomplete",
"capture_reason_restarted": "image source restarted",
"capture_reason_zero_frames": "no frame was written",
"capture_reason_write_error": "disk write failed",
"capture_reason_video_missing": "no video frame was kept",
"capture_reason_video_undecodable": "the video cannot be decoded",
"capture_reason_dropped": "frames were dropped",
"capture_reason_segments": "unexpected number of video segments",
"capture_reason_timeout": "closing timed out",
"capture_wait": "recording is not ready; the hold cannot be confirmed yet"
}, },
"finger": { "finger": {
"title": "Finger calibration-point debug", "title": "Finger calibration-point debug",
@@ -589,6 +604,7 @@
"overview": { "overview": {
"capture_in_progress": "In progress", "capture_in_progress": "In progress",
"saved": "Analysis saved; hover for its location.", "saved": "Analysis saved; hover for its location.",
"saved_summary": "Review saved and summary published: {path} → {summary}",
"open_session": "Open calibration session…", "open_session": "Open calibration session…",
"empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.", "empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.",
"title": "Calibration record overview", "title": "Calibration record overview",
@@ -654,7 +670,8 @@
"guide_missing": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: this site has no attempt; choose a collected site or acquire it.", "guide_missing": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: this site has no attempt; choose a collected site or acquire it.",
"guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.", "guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.",
"guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.", "guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.",
"guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis." "guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis.",
"video": "View recording"
}, },
"quality": { "quality": {
"open": "Repeatability and validation…", "open": "Repeatability and validation…",
@@ -724,6 +741,7 @@
"uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit", "uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit",
"issues": "{count} incomplete attempts excluded from fitting. Inspect raw signals for stability.", "issues": "{count} incomplete attempts excluded from fitting. Inspect raw signals for stability.",
"saved": "Saved new review: {path}", "saved": "Saved new review: {path}",
"saved_summary": "Review saved and summary published: {path} → {summary}",
"col_trial": "Trial", "col_trial": "Trial",
"col_branch": "Phase", "col_branch": "Phase",
"col_load": "Reference (N)", "col_load": "Reference (N)",
@@ -733,5 +751,35 @@
"col_duration": "Hold (s)", "col_duration": "Hold (s)",
"col_quality": "Quality", "col_quality": "Quality",
"col_drift": "Drift (raw/s)" "col_drift": "Drift (raw/s)"
},
"video": {
"title": "Video review",
"header": "{trial} · step {step} ({branch} {load} N) · review {status} · {samples} valid samples",
"position": "Frame {index}/{total} · id {frame} · segment {segment} frame {segment_frame} · basis {basis}",
"play": "Play",
"pause": "Pause",
"previous": "Previous",
"next": "Next",
"frame_missing": "This frame was not written to the video (dropped from the queue)",
"reason_missing": "Cannot open the video: {detail}",
"reason_no_frame_source": "This recording has no usable video evidence (no camera, or no frame was written)",
"reason_no_video_frame": "No valid frame of this hold was written to the video",
"reason_trial_not_found": "This trial is not in the record",
"reason_window_not_found": "This hold window is not in the trial",
"reason_video_undecodable": "The video file cannot be decoded",
"reason_decode": "Decoding this frame failed",
"cursor": "Current frame",
"reference": "Actual reference",
"curve_title": "Raw force curve for this hold",
"basis_unknown": "not recorded",
"open": "View recording"
},
"summary": {
"failed": "Summary export failed: {detail}. The recording is unchanged; you can retry.",
"published": "Summary published: {path}",
"retry": "Publish summary again",
"done": "Summary published again: {path}",
"reason_hold_not_found": "This hold attempt is not in the record",
"reason_hold_not_complete": "This hold attempt was discarded by a remeasure"
} }
} }
+50 -2
View File
@@ -392,7 +392,22 @@
"col_levels": "档位 (N)", "col_levels": "档位 (N)",
"col_hold": "保持 (s)", "col_hold": "保持 (s)",
"col_repeats": "重复", "col_repeats": "重复",
"col_done": "完成" "col_done": "完成",
"capture": "录像:{status} · 帧 {frames} · 丢帧 {dropped} · {reason}",
"capture_idle": "未就绪",
"capture_recording": "录制中",
"capture_closing": "收尾中",
"capture_complete": "完整",
"capture_incomplete": "证据不完整",
"capture_reason_restarted": "图像来源已重启",
"capture_reason_zero_frames": "没有写入任何帧",
"capture_reason_write_error": "写盘失败",
"capture_reason_video_missing": "没有录像帧",
"capture_reason_video_undecodable": "录像无法解码",
"capture_reason_dropped": "队列丢帧",
"capture_reason_segments": "录像分段数不符",
"capture_reason_timeout": "收尾超时",
"capture_wait": "录像未就绪,暂时不能确认保持"
}, },
"finger": { "finger": {
"title": "手指标定点调试", "title": "手指标定点调试",
@@ -589,6 +604,7 @@
"overview": { "overview": {
"capture_in_progress": "采集中", "capture_in_progress": "采集中",
"saved": "分析结果已保存;保存位置见提示。", "saved": "分析结果已保存;保存位置见提示。",
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
"open_session": "打开标定会话…", "open_session": "打开标定会话…",
"empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。", "empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。",
"title": "标定记录总览", "title": "标定记录总览",
@@ -654,7 +670,8 @@
"guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。", "guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。",
"guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。", "guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。",
"guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。", "guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。",
"guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。" "guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。",
"video": "查看录像"
}, },
"quality": { "quality": {
"open": "重复性与验证…", "open": "重复性与验证…",
@@ -724,6 +741,7 @@
"uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合", "uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合",
"issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。", "issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。",
"saved": "已另存:{path}", "saved": "已另存:{path}",
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
"col_trial": "试验", "col_trial": "试验",
"col_branch": "阶段", "col_branch": "阶段",
"col_load": "参考力 (N)", "col_load": "参考力 (N)",
@@ -733,5 +751,35 @@
"col_duration": "保持 (s)", "col_duration": "保持 (s)",
"col_quality": "质量说明", "col_quality": "质量说明",
"col_drift": "漂移 (raw/s)" "col_drift": "漂移 (raw/s)"
},
"video": {
"title": "录像复核",
"header": "{trial} · 第 {step} 步({branch} {load} N)· 复核状态 {status} · 有效样本 {samples}",
"position": "第 {index}/{total} 帧 · 帧号 {frame} · 分段 {segment} 第 {segment_frame} 帧 · 时间基准 {basis}",
"play": "播放",
"pause": "暂停",
"previous": "上一帧",
"next": "下一帧",
"frame_missing": "该帧没有写入录像(队列丢帧)",
"reason_missing": "无法打开录像:{detail}",
"reason_no_frame_source": "这段录像没有可用的视频证据(未接相机或没有写入帧)",
"reason_no_video_frame": "该保持窗口内没有已写入视频的有效帧",
"reason_trial_not_found": "记录中找不到这次试验",
"reason_window_not_found": "在该试验中找不到这个保持窗口",
"reason_video_undecodable": "录像文件无法解码",
"reason_decode": "解码这一帧失败",
"cursor": "当前帧",
"reference": "实际参考",
"curve_title": "原始力曲线与该保持窗口",
"basis_unknown": "未记录",
"open": "查看录像"
},
"summary": {
"failed": "汇总导出失败:{detail}。采集结果没有改变,可以重试。",
"published": "汇总已发布:{path}",
"retry": "重新导出汇总",
"done": "汇总已重新发布:{path}",
"reason_hold_not_found": "记录中找不到这个保持尝试",
"reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面"
} }
} }
+18 -10
View File
@@ -74,13 +74,14 @@ finger_F001_<时间>_<标识>/
| 文件 | 内容及写入时机 | | 文件 | 内容及写入时机 |
|---|---| |---|---|
| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 | | 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 |
| trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、steps;采集 session_id 是独立的整数编号 | | trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、`capture_status`、`capture_reason`、`capture_report`、steps;采集 session_id 是独立的整数编号 |
| `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 | | `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 |
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 | | `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
| `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 | | `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 |
| `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 | | `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 |
| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | | `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 |
| 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 | | 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 |
| `derived/summary_*/`、`summary-latest.json` | 汇总四件文件与最后成功发布的指针;字段见[多点格式](multipoint-data-format.zh.md) |
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable | | `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable |
| `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | | `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 |
@@ -94,7 +95,14 @@ finger_F001_<时间>_<标识>/
| 参考有效性 | 人工声明压缩为正;未测 XYZ 和坐标系留空,不补零,不直接把法向值当平台 Z;hold_complete=0 的窗口不能用于正式拟合 | | 参考有效性 | 人工声明压缩为正;未测 XYZ 和坐标系留空,不补零,不直接把法向值当平台 Z;hold_complete=0 的窗口不能用于正式拟合 |
| `frames.csv` | `frame_id`、`source_frame_id`、`t_rel_s`、`t_host_mono_ns`、`t_host_utc_ns`、`acquired_at`、`time_basis`、`media_time_ms`、`segment`、`segment_frame`、`in_video`、`dropped`、`capture_dropped` | | `frames.csv` | `frame_id`、`source_frame_id`、`t_rel_s`、`t_host_mono_ns`、`t_host_utc_ns`、`acquired_at`、`time_basis`、`media_time_ms`、`segment`、`segment_frame`、`in_video`、`dropped`、`capture_dropped` |
参考窗口采用起点包含、终点不包含的时间范围。换档没有恒定力标签,视频和算法输出仍保存。`trial_finished` 表示请求正常结束;最终是否 complete 以排空后的 manifest 为准。保存失败不能当作成功;重试不覆盖原文件。 参考窗口起点包含、终点不包含;换档无恒定力标签,视频与算法输出照常保存。最终状态以排空后的 manifest 为准;保存失败不算成功,重试不覆盖原文件。
录像完整性由 `capture_guard.capture_verdict()` 统一判定:写入帧数、录像帧数、单分段、队列丢帧、
视频可解码、未中止、采集中途来源会话未变,全部通过才写 complete,否则 incomplete 并保留原因码
(如 `zero_frames`、`video_undecodable`、`source_restarted`、`closing_timeout`);排空期间界面
轮询 `poll()` 而不阻塞。汇总见[多点格式的汇总一节](multipoint-data-format.zh.md):同一套
`summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv`
数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。
### 多点会话格式 ### 多点会话格式
@@ -110,9 +118,9 @@ finger_F001_<时间>_<标识>/
| session/site/trial 关联 → 根清单;数据包 → 视频及 CSV | Autocal 会话;shared recorder 负责文件写入 | | session/site/trial 关联 → 根清单;数据包 → 视频及 CSV | Autocal 会话;shared recorder 负责文件写入 |
| 完整 trial + 帧身份/电脑时间 → 人工复核窗口 → 加载/卸载、重复性、trial 留出验证及 UV 着色 | Autocal 初步分析;实机验收后置 | | 完整 trial + 帧身份/电脑时间 → 人工复核窗口 → 加载/卸载、重复性、trial 留出验证及 UV 着色 | Autocal 初步分析;实机验收后置 |
主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。 主时间是电脑取得该帧时绑定的单调时间,UTC 仅供追溯,处理完成时间不能代替采集时间。FT/ECAT 后续按同一接收时间关联,不按行号配对。`host_replay` 只是本次解码时间,不能恢复原实验同步。
“标定记录”页打开会话,核对型号/SN、时间、来源和采集状态。选点及单次试验,先看时间曲线及诊断,再“检查保持段…”,最后“保存分析结果”。采集页“查看标定数据…”也跳转至此。 “标定记录”页打开会话,核对型号/SN、来源和采集状态;选点及单次试验先看时间曲线,再“检查保持段…”,最后保存分析结果。采集页“查看标定数据…”也跳转至此。
| 总览 | 规则 | | 总览 | 规则 |
|---|---| |---|---|
@@ -126,16 +134,16 @@ finger_F001_<时间>_<标识>/
拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。 拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。
采集页显示 FN/FX/FY raw 及最近 60 秒曲线;已有标定的轴另显示 N。无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。 采集页显示 FN/FX/FY raw 及最近 60 秒曲线,已标定轴另显示 N;无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。
实时 UV 图有真实背景、位移箭头、深度三个开关;关闭背景显示纯色画布。深度按当前帧有效 UV 有限值范围自适应,显示范围;跨帧同色不代表同值,算法单位不是毫米。缺失或过期字段不绘制并提示。显示不改变录像及坐标。 实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。显示不改变录像及坐标。
检查保持段的原始曲线、样本数、标准差和漂移 raw/s;起止时间以保持开始为零,只能在原段内调整。勾选“此时间段已检查稳定”,点击“确认选中时间段并重算”,再保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合,否则显示候选拟合。确认稳定不代表精度合格;尚无自动验收阈值。 检查保持段曲线、样本数、标准差和漂移;起止以保持开始为零,只能在原段内调整。勾选“已检查稳定”后重算并保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合;确认稳定不代表精度合格。
分析保存 selections、拟合、样本轨迹、输入 SHA256、配置及版本;确认的初始零载段保存基准图像、帧身份及哈希。“载入历史分析…”选 review.json 重算;输入身份或哈希变化时拒绝载入及保存。结果另存,不修改 reference.csv。 分析保存 selections、拟合、样本轨迹、输入 SHA256 与版本;确认的初始零载段另存基准图像、帧身份与哈希。输入变化时拒绝载入与保存,结果不改 reference.csv。
“重复性与验证…”使用已确认窗口,site.quality 保存跨 trial 同分支/参考力的极差和样本标准差、同 trial 卸载减加载 raw 差及最终减初始零载。参考力须完全相同,不插值;不足两次不算重复性。 “重复性与验证…”使用已确认窗口,site.quality 保存跨 trial 同参考力的极差与标准差、卸载减加载差及最终减初始零载;参考力须完全相同,不插值,不足两次不算重复性。
验证逐次留出完整 trial,用其余 trial 的零载/加载拟合预测留出窗口;窗口等权,报告 RMSE/MAE/最大误差及超出训练范围数量,训练不足时说明原因。交叉验证不能代替独立实验或正式实时系数。 验证逐次留出完整 trial,用其余 trial 拟合预测留出窗口,报告 RMSE/MAE/最大误差及超出训练范围数量,训练不足时说明原因。交叉验证不能代替独立实验或正式系数。
跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。 跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。
+33 -2
View File
@@ -1,6 +1,6 @@
# Autocal 多点标定会话格式(`autocal-multipoint-1.0`) # Autocal 多点标定会话格式(`autocal-multipoint-1.0`)
状态:2026-10-14 记录格式与只读复核已接通,仅软件验证,待实机验收。坐标与载荷约定、finger 状态:2026-10-17 记录格式、只读复核、录像完整性判定与操作员汇总已接通,仅软件验证,待实机验收。坐标与载荷约定、finger
会话格式见[标定操作与数据格式](calibration-data-format.zh.md);姿态与接触高度见 会话格式见[标定操作与数据格式](calibration-data-format.zh.md);姿态与接触高度见
[逆运动学说明](inverse-kinematics.md);运动边界与停机见[操作流程](workflow.md)。本页不会自动移动平台。 [逆运动学说明](inverse-kinematics.md);运动边界与停机见[操作流程](workflow.md)。本页不会自动移动平台。
@@ -24,7 +24,7 @@ sites/<site_id>/repeat-00N/
|---|---| |---|---|
| 根 `session.json` | `format_version=autocal-multipoint-1.0`、session_id、型号/SN、`coordinate_frame`、`force_to_tool_rotation`、`normal_projection`、力范围、`sync`、sites、trials、`completion`、`status`/`reason`;`platform_motion` 与 `automatic_force_control` 均为 false | | 根 `session.json` | `format_version=autocal-multipoint-1.0`、session_id、型号/SN、`coordinate_frame`、`force_to_tool_rotation`、`normal_projection`、力范围、`sync`、sites、trials、`completion`、`status`/`reason`;`platform_motion` 与 `automatic_force_control` 均为 false |
| site 记录 | `site_id`、`position`/`position_frame`、`normal_sensor`、`pose`、`uv_px`、`levels_n`、`hold_s`、`repeats`;`pose` 为空或 `in_range=false` 的点只保存、不下发 | | site 记录 | `site_id`、`position`/`position_frame`、`normal_sensor`、`pose`、`uv_px`、`levels_n`、`hold_s`、`repeats`;`pose` 为空或 `in_range=false` 的点只保存、不下发 |
| repeat `session.json` | `trial_id`、site、`pose`、`uv_px`、`steps[{step_index, branch, target_load_n}]`、`sync`、`images`、`status` | | repeat `session.json` | `trial_id`、site、`pose`、`uv_px`、`steps[{step_index, branch, target_load_n}]`、`sync`、`images`、`status`;录像收尾后另有 `capture_status`、`capture_reason`、`capture_report`(共享记录的统计与问题清单) |
| `reference.csv` | 每个实际开始的保持窗口一行:`step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、site 坐标与坐标系、`repeat_index`/`repeat_count`、`hold_complete`、`reference_source`、`fx_N`/`fy_N`/`fz_N`、力到工具的旋转、`nx`/`ny`/`nz`、`pose_*`、`normal_aligned_to` | | `reference.csv` | 每个实际开始的保持窗口一行:`step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、site 坐标与坐标系、`repeat_index`/`repeat_count`、`hold_complete`、`reference_source`、`fx_N`/`fy_N`/`fz_N`、力到工具的旋转、`nx`/`ny`/`nz`、`pose_*`、`normal_aligned_to` |
| `events.csv` | 电脑单调时间、UTC、事件类型与明细 | | `events.csv` | 电脑单调时间、UTC、事件类型与明细 |
| `capture/` | 共享记录器的录像会话(`session.json`、`frames.csv`、`forces.csv`、`raw_*.mp4`);模拟运行或未接相机时不产生,原因记在该 repeat 的 `images.reason` | | `capture/` | 共享记录器的录像会话(`session.json`、`frames.csv`、`forces.csv`、`raw_*.mp4`);模拟运行或未接相机时不产生,原因记在该 repeat 的 `images.reason` |
@@ -36,9 +36,22 @@ sites/<site_id>/repeat-00N/
| branch | 零载窗口写 `baseline`,其余为 `load`/`unload`,位次由 `load_steps()` 决定;没有 `stability_verified` 列,该标志只由复核时选中的子区间产生 | | branch | 零载窗口写 `baseline`,其余为 `load`/`unload`,位次由 `load_steps()` 决定;没有 `stability_verified` 列,该标志只由复核时选中的子区间产生 |
| 参考力 | 称重模式是操作员输入的法向力;六轴模式是 `F_ref_normal_N = (R @ F) · tool_axis`,`R` 为本次会话输入力到工具的旋转。两者都是人工声明的参考,不是独立测量,也不能替代 FT | | 参考力 | 称重模式是操作员输入的法向力;六轴模式是 `F_ref_normal_N = (R @ F) · tool_axis`,`R` 为本次会话输入力到工具的旋转。两者都是人工声明的参考,不是独立测量,也不能替代 FT |
| 时间关联 | 帧属于某个保持窗口,当且仅当它的 `t_host_mono_ns` 落在 `[t_start_host_mono_ns, t_end_host_mono_ns)`;不按行号配对 | | 时间关联 | 帧属于某个保持窗口,当且仅当它的 `t_host_mono_ns` 落在 `[t_start_host_mono_ns, t_end_host_mono_ns)`;不按行号配对 |
| 帧—力关联 | `forces.csv` 没有采集时间,只有 `frame_id`;两文件按 `frame_id` 关联,重复的帧号没有唯一时间,其样本被排除并记 `duplicate_frame_identity`,不按行号回退 |
| 归属 | 当前 repeat 先写入根清单与自己的 `session.json`,再以显式 trial 身份启动录像;启动失败不得沿用上一 repeat 的目录,也不得用 `trials[-1]` 指代新 repeat |
| 来源会话 | 一个 repeat 只接受启动时那个采集会话的帧。相机在录制中重启会换会话并复用帧号,因此写入被拒绝、repeat 判为 `incomplete`(`source_restarted`),需要新的 trial |
| 姿态 | `pose` 是求解结果快照(含 `in_range`、`upright`、`residual_deg`、`branch_reason`);执行前按当前安装设置重新求解,记录值只作对照 | | 姿态 | `pose` 是求解结果快照(含 `in_range`、`upright`、`residual_deg`、`branch_reason`);执行前按当前安装设置重新求解,记录值只作对照 |
| 目录名 | 会话目录带型号、SN、时间与唯一标识;`repeat-00N` 编号同点重复与中止重试 | | 目录名 | 会话目录带型号、SN、时间与唯一标识;`repeat-00N` 编号同点重复与中止重试 |
## 录像收尾与完整性
停止时先摘除 sink,再异步排空;Qt 定时器轮询 `Campaign.poll()`,收尾期间界面仍可响应。终态由
`capture_guard.capture_verdict()` 判定,只有同时满足写入帧数大于零、录像帧数大于零、单分段、无队列
丢帧、视频首帧可解码、未中止且来源未重启时才是 `complete`;否则写 `incomplete` 并保留原因码
(`zero_frames`、`write_error`、`video_missing`、`video_undecodable`、`segment_count`、
`dropped_data`、`dropped_video`、`source_restarted`、`closing_timeout`、`aborted`)。
收尾超时保持 `closing_timeout`,不复用仍在写入的目录;旧记录没有 `capture_status` 时读取端记为
unknown,不把 `images.captured=true` 当成录像已验证。
## 只读复核输入 ## 只读复核输入
`session_review.review_session()` 按根清单的 `format_version` 分流,多点会话走 `session_review.review_session()` 按根清单的 `format_version` 分流,多点会话走
@@ -59,3 +72,21 @@ sites/<site_id>/repeat-00N/
多点复核的 `formal_conclusion` 恒为 `false`:参考力是人工声明,帧来源可能缺失,格式里也没有独立 多点复核的 `formal_conclusion` 恒为 `false`:参考力是人工声明,帧来源可能缺失,格式里也没有独立
验证。结果只写 `derived/review_<时间>_<标识>/review.json`,原始文件哈希不变;未接入 FT/ECAT 验证。结果只写 `derived/review_<时间>_<标识>/review.json`,原始文件哈希不变;未接入 FT/ECAT
前不写回 SDK 系数,也不引入第二种采集格式。 前不写回 SDK 系数,也不引入第二种采集格式。
## 操作员汇总
`summary_export.publish_summary()` 在 `derived/summary_<时间>_<标识>/` 写 `summary.html`、
`summary.json`、`baseline_load_data.csv`、`sensitivity_values.csv`,再原子替换根目录的
`summary-latest.json`(相对路径、`summary_id`、输入与文件哈希)。采集终态自动发布候选汇总;人工
复核保存后再发布已复核汇总;失败保留旧指针并可重试,采集结果不变。
两张表都带 `schema_version`、`summary_id` 与来源文件:
| 表 | 行单位 | 关键字段 |
|---|---|---|
| `baseline_load_data.csv` | 每个实际保持尝试(含被重测废弃的) | `session_id`、`trial_id`、`hold_id`(`reference.csv` 数据行序号)、`step_index`、`branch`、`reference_load_n`、`window_method`、样本计数、三轴 raw 均值与标准差、`trial_status`、`capture_status`、`review_status`、`eligible`、`excluded_reason` |
| `sensitivity_values.csv` | 每点、每 trial、每轴、每审核集合 | `method=raw_on_reference_ols_v1`,`slope_raw_per_N`、`intercept_raw`、`R2`、`rmse_raw`、`endpoint_raw_per_N`、`initial_step_raw`、参考力范围、`window_count`、`status` |
默认候选窗口取保持后半段(`hold_second_half_v1`),人工确认后取复核区间的精确起止
(`review_selection_v1`);从不自动标记稳定。`stable_means.csv` 保留原列与过滤方法,只把帧—力
关联改为按 `frame_id`;因此重复帧号的样本不再参与旧表均值,其余数值规则不变。
@@ -1,6 +1,6 @@
# 录像和操作员汇总数据契约提案 # 录像和操作员汇总数据契约提案
状态:2026-10-16,待实施。属于[实施计划](recording-summary-plan.zh.md);既有采集字段仍以[单次格式](calibration-data-format.zh.md)及[多点格式](multipoint-data-format.zh.md)为准。以下新增字段、接口和文件名均是提案,不能按已实现能力使用。 状态:2026-10-17 已按本契约实现,仅软件验证。属于[实施计划](recording-summary-plan.zh.md);既有采集字段仍以[单次格式](calibration-data-format.zh.md)及[多点格式](multipoint-data-format.zh.md)为准。本页描述的是当前行为,`formal_conclusion` 仍恒为 false。
## 文件组织 ## 文件组织
@@ -39,7 +39,7 @@
不建立第二份录像,不改变两种格式的原始目录。新汇总沿用样例文件名,但明确版本化字段;旧 `stable_means.csv` 保留原接口,不将新表悄悄写成旧表。新读取器缺少指针时仍可打开旧会话,绝不通过修改旧 manifest 补造证据。 不建立第二份录像,不改变两种格式的原始目录。新汇总沿用样例文件名,但明确版本化字段;旧 `stable_means.csv` 保留原接口,不将新表悄悄写成旧表。新读取器缺少指针时仍可打开旧会话,绝不通过修改旧 manifest 补造证据。
导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针。原始文件、历史 review 和已发布 summary 均不覆盖。 导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针(取消由调用方传入,界面暂未提供取消按钮)。原始文件、历史 review 和已发布 summary 均不覆盖。
## 录像身份、时间和生命周期 ## 录像身份、时间和生命周期
@@ -1,6 +1,6 @@
# Autocal 录像复核与汇总实施计划 # Autocal 录像复核与汇总实施计划
状态:2026-10-16,待实施。范围:补齐录像可靠性、按事件回放和操作员汇总;不改变运动控制,不部署标定系数。详细字段见[数据契约提案](recording-summary-contract.zh.md),DeepSeek 执行清单见[任务分工](recording-summary-tasks.zh.md)。本计划补充[原实施计划](implementation-plan.zh.md),不把草稿描述当作功能验收。 状态:2026-10-17 已实施,仅软件验证,待实机验收。范围不变:补齐录像可靠性、按事件回放和操作员汇总;不改变运动控制,不部署标定系数。实现落在 `frames_index.py`、`capture_guard.py`、`summary.py`、`summary_export.py`、`video_review.py` 与相应界面,详细字段见[数据契约](recording-summary-contract.zh.md),执行与验证结果见[任务分工](recording-summary-tasks.zh.md)。本计划补充[原实施计划](implementation-plan.zh.md),离线测试全绿不代表硬件验收。
## 现状与问题 ## 现状与问题
+26 -12
View File
@@ -1,6 +1,6 @@
# DeepSeek 子代理执行清单 # DeepSeek 子代理执行清单
状态:2026-10-16,全部待实施。本清单与[主计划](recording-summary-plan.zh.md)、[数据契约](recording-summary-contract.zh.md)一起交给 DeepSeek 主代理。本次仅完成规划,没有启动 DeepSeek 实现;当前工具不提供 DeepSeek 模型。 状态:2026-10-17 已实施,仅软件验证,待实机验收。本清单与[主计划](recording-summary-plan.zh.md)、[数据契约](recording-summary-contract.zh.md)配套;下面的复选框记录实际完成情况,末尾是本次真实执行结果。
## 主代理指令 ## 主代理指令
@@ -26,7 +26,7 @@ A、B、C 在 T0 后可并行,但 `multipoint.py` 的改动优先归 A;B 先
- [ ] 读 `trial.py`、`multipoint.py`、共享 `record/experiment.py`、`record/playback.py`,确认 report 和帧身份可用字段;冻结输入输出,避免接口臆造。 - [ ] 读 `trial.py`、`multipoint.py`、共享 `record/experiment.py`、`record/playback.py`,确认 report 和帧身份可用字段;冻结输入输出,避免接口臆造。
- [ ] 建 `tests/evidence_helpers.py`,含双 repeat、两种格式、缺视频、重复 frame_id、断流、重测及非线性样例。可解码视频使用真实小文件,不能仅写假 MP4 字节。 - [ ] 建 `tests/evidence_helpers.py`,含双 repeat、两种格式、缺视频、重复 frame_id、断流、重测及非线性样例。可解码视频使用真实小文件,不能仅写假 MP4 字节。
- [ ] 将 proposed 接口落实为:窗口选择复用现有 review;summary 构建返回普通 dict;export 接收不可变结果;回放定位接收 trial/hold 身份,返回 segment 和帧号。现有公共 recorder 签名保持不变。 - [x] 将 proposed 接口落实为:窗口选择复用现有 review;summary 构建返回普通 dict;export 接收不可变结果;回放定位接收 trial/hold 身份,返回 segment 和帧号。现有公共 recorder 签名保持不变。
验证:夹具能同时驱动现有读取器和新增回归;用小型独立数值例子断言预期。缺共享 API 时仅在有两个实际调用者的情况下提取,不导入 Studio 私有代码。 验证:夹具能同时驱动现有读取器和新增回归;用小型独立数值例子断言预期。缺共享 API 时仅在有两个实际调用者的情况下提取,不导入 Studio 私有代码。
@@ -67,26 +67,40 @@ dd02-gl 的 60/15 行与重复 site_id 必须记录为历史案例;测试用
## D:集成与独立验收(2–3 日) ## D:集成与独立验收(2–3 日)
- [ ] D1:主代理修改 `multipoint.py`、`trial.py`、`ui/record_overview_dialog.py` 与 locale,将采集终态触发候选导出、保存 review 触发新汇总串起来;导出失败可重试且不改采集结果。加入 `test_summary_lifecycle.py`。 - [x] D1:主代理修改 `multipoint.py`、`trial.py`、`ui/record_overview_dialog.py` 与 locale,将采集终态触发候选导出、保存 review 触发新汇总串起来;导出失败可重试且不改采集结果。加入 `test_summary_lifecycle.py`。
- [ ] D1 后同步三份既有格式/实施说明和新契约状态,注册文档;不把计划全文复制到多个位置。 - [x] D1 后同步三份既有格式/实施说明和新契约状态,注册文档;不把计划全文复制到多个位置。
- [ ] D2:换一名只读验收代理检查完整流程及失败场景,核对原始文件哈希、录像归属、结果数字和旧消费者。主代理处理发现并复跑相关检查。 - [x] D2:一名只读验收代理独立复核(562 项实跑 exit 0);提出 3 项 major、9 项 minor,主代理已修 3 项 major 与 6 项 minor,其余记为未接线或已接受。
首个检查点在 A2/B2/C1 后:身份、收尾、公式全部通过才接界面。最终检查点:新进程打开两种会话,选择已确认窗口播放,打开报告及两 CSV,中止/缺视频能解释原因。任何必需环境缺失或测试跳过都列为未验证。 首个检查点在 A2/B2/C1 后:身份、收尾、公式全部通过才接界面。最终检查点:新进程打开两种会话,选择已确认窗口播放,打开报告及两 CSV,中止/缺视频能解释原因。任何必需环境缺失或测试跳过都列为未验证。
## 验证命令模板 ## 验证命令与本次结果
以下命令是实施后的计划,不是本次已执行结果。运行前核对解释器和实际新增测试名;在 app 根执行,主代理选择本波次测试文件。现有 `conftest.py` 使用旧 outputs 临时目录,T0 须将本任务测试夹具定向 scratch,避免测试运行污染真实偏好或输出。 以下命令已在 app 根执行;未运行真实设备。
```powershell ```powershell
$pythonExe = 'D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe' $pythonExe = 'D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe'
& $pythonExe --version & $pythonExe --version
& $pythonExe -m pytest apps/autocal/tests/test_multipoint.py apps/autocal/tests/test_multipoint_recording.py apps/autocal/tests/test_manual_trial.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-a1 & $pythonExe -m pytest apps/autocal/tests -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-full
& $pythonExe -m pytest apps/autocal/tests/test_summary.py apps/autocal/tests/test_summary_fit.py apps/autocal/tests/test_summary_export.py apps/autocal/tests/test_summary_lifecycle.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-b1 & $pythonExe -m pytest shared/tests/test_experiment_recorder.py shared/tests/test_record_lifecycle.py shared/tests/test_record_semantics.py shared/tests/test_playback.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-shared
& $pythonExe -m pytest apps/autocal/tests/test_video_review.py apps/autocal/tests/test_session_review.py apps/autocal/tests/test_session_review_multipoint.py apps/autocal/tests/test_record_overview.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-c1
& $pythonExe -m pytest shared/tests/test_experiment_recorder.py shared/tests/test_record_lifecycle.py shared/tests/test_record_semantics.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-shared1
& $pythonExe tools/lint_gate.py & $pythonExe tools/lint_gate.py
& $pythonExe apps/autocal/tools/panel_check.py & $pythonExe apps/autocal/tools/panel_check.py
& $pythonExe D:\orisys_ws\scripts\doc_lint.py & $pythonExe D:\orisys_ws\scripts\doc_lint.py
``` ```
若修改 shared,另跑实际受影响的 Studio 读取/记录测试;不以 Autocal 测试替代消费者回归。最终记录真实命令、收集数、通过/跳过数和手工操作证据。真实设备试验单独排期,离线全绿不算硬件验收。 | 项目 | 结果 |
|---|---|
| T0 | `frames_index.py`(帧/力身份关联、重复帧排除、采集目录解析)、`tests/evidence_helpers.py`(真实可解码 MP4、两种会话样例、可重启来源的假 worker)、`test_frames_index.py` |
| A | `capture_guard.py`(`CaptureGuard` 与 `capture_verdict`)、`multipoint.py` 显式 repeat 归属/收尾状态机/来源重启判定、`trial.py` 复用同一判定、界面录像状态与"未就绪不能确认"、两份 locale |
| B | `summary.py`(窗口表、逐轴前向 OLS、端点/初始阶跃、旧 `stable_means.csv` 兼容行)、`summary_export.py`(四件文件、原子指针、输入哈希复核、取消/倒退保护) |
| C | `video_review.py`(按身份定位与解码、分段切换、释放 decoder)、`ui/video_review_dialog.py`,记录总览与保持复核对话框的录像入口 |
| D | 采集终态与复核保存触发发布;失败可重试且不改采集结果;两份格式说明与三份计划状态已同步 |
| Autocal 全量 | 562 项,0 失败、0 跳过,62.0 s;junit 证据在 `.scratch/autocal-recording-summary/final-autocal.xml` |
| shared 回归 | 4 个文件全部通过(recorder、lifecycle、semantics、playback) |
| `tools/lint_gate.py` | `status: clean`,仅 1 条已解释的 BLE001 抑制提示 |
| `apps/autocal/tools/panel_check.py` | 260 项中 2 项失败:finger 页 `QDoubleSpinBox` 高度大于父容器。用 HEAD 的独立 worktree 复跑同样失败,属先前存在,不是本次改动引入 |
| `scripts/doc_lint.py` | 本次涉及文档均在 reference 上限 3000 内,门禁通过 |
| D2 独立验收 | 只读评审提出 3 项 major(来源停止仍判完整、模拟写成完整、收尾期可开新场)与 9 项 minor;3 项 major 与 6 项 minor 已修并加回归测试,2 项记为已接受(首帧解码探测在收尾轮询内、有界)与未接线(发布取消只有库级参数,无界面按钮) |
| 回归数量 | 新增 est_frames_index、 est_multipoint_recording、 est_summary、 est_summary_fit、 est_summary_export、 est_summary_lifecycle、 est_video_review、 est_video_review_dialog |
未验证:真实相机与控制器、24 小时分段的解码耗时、`D:\orisys_ws\data\dd02-gl` 的现场私有数据路径(只核对行数,不作为夹具)、D2 独立人工验收。旧独立 `stable_means.csv` 目前只有 `summary.legacy_window_rows()` 映射与测试,尚未接入界面导入;会话内旧记录可读,缺视频时按原因排除。若以后改 shared,另跑实际受影响的 Studio 读取/记录测试,不以 Autocal 测试替代消费者回归。
@@ -0,0 +1,225 @@
"""Capture honesty: one source session per recording, and one closing verdict.
Two defects motivated this module.
*The sink outlives the session.* A worker's record sinks are registered on the
worker object and survive ``start()``. When a camera is restarted while a
recording runs, the recorder keeps accepting packets whose ``session_id`` is the
new one and whose ``frame_id`` begins again, so two acquisitions are written into
one directory under one identity. The recorder cannot see this on its own: it
writes its own session id into ``forces.csv``. A :class:`CaptureGuard` sits
between the worker and the recorder, refuses the packet that would mix identities
and remembers why.
*"Stopped" is not "complete".* The multi-point campaign used to mark a repeat
complete from the operator's confirmation alone, without asking the writer what
happened. :func:`capture_verdict` is the single answer both recording paths use:
zero frames, a write error, a missing or undecodable video, more than one segment,
dropped data, a restarted source or an aborted operator all keep the evidence
incomplete, and each one names its reason.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
#: Reason codes. Tokens, not sentences: the UI localises them.
SOURCE_RESTARTED = "source_restarted"
SOURCE_STOPPED = "source_stopped"
NOT_RECORDED = "not_recorded"
WRITE_ERROR = "write_error"
ZERO_FRAMES = "zero_frames"
VIDEO_MISSING = "video_missing"
VIDEO_UNDECODABLE = "video_undecodable"
SEGMENT_COUNT = "segment_count"
DROPPED_DATA = "dropped_data"
DROPPED_VIDEO = "dropped_video"
CLOSING_TIMEOUT = "closing_timeout"
ABORTED = "aborted"
PENDING = "pending"
COMPLETE = "complete"
INCOMPLETE = "incomplete"
#: How long a closing writer may take before it is reported as incomplete.
CLOSING_TIMEOUT_S = 20.0
class CaptureGuard:
"""Sink wrapper: hands packets to the recorder only for the expected session."""
def __init__(self, *, expected_session_id: int, recorder) -> None:
self.expected_session_id = int(expected_session_id)
self.recorder = recorder
self.packets = 0
self.refused = 0
self.failed = False
self.reason = ""
def __call__(self, packet) -> None:
self.packets += 1
session = getattr(packet, "session_id", None)
if session is None or int(session) != self.expected_session_id:
# Do not forward: a row from another acquisition session has no
# identity inside this recording, and labelling it with the old
# session id would make the files lie.
self.refused += 1
self.failed = True
self.reason = SOURCE_RESTARTED
return
self.recorder.on_packet(packet)
def as_dict(self) -> dict:
return {
"expected_session_id": self.expected_session_id,
"packets": self.packets,
"refused": self.refused,
"failed": self.failed,
"reason": self.reason,
}
@dataclass(frozen=True)
class CaptureVerdict:
"""The one answer about whether a recording is usable evidence."""
status: str
reason: str = ""
report: dict | None = None
frames: int = 0
video_frames: int = 0
segments: int = 0
dropped_data: int = 0
dropped_video: int = 0
duration_s: float = 0.0
source_restarted: bool = False
@property
def complete(self) -> bool:
return self.status == COMPLETE
def probe_segments(directory: Path | None) -> tuple[bool, str]:
"""Decode the first frame of every segment. A non-empty file is not a video."""
if directory is None:
return False, "no directory"
segments = sorted(Path(directory).glob("raw_*.mp4"))
if not segments:
return False, "no segment file"
try:
import cv2
except ImportError: # pragma: no cover - cv2 is a recording dependency
# The recorder cannot have written an MP4 without cv2 either, so this is
# a broken environment rather than an undeodable recording; it is still
# not evidence and must not pass silently.
return False, "cv2 unavailable"
for path in segments:
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
return False, path.name
ok, _image = capture.read()
if not ok:
return False, path.name
finally:
capture.release()
return True, ""
def capture_report_dict(recorder, guard: CaptureGuard | None = None) -> dict | None:
if recorder is None:
return None
report = recorder.report
directory = recorder.directory
payload: dict = {
"directory": str(directory) if directory is not None else "",
"state": recorder.state,
"guard": guard.as_dict() if guard is not None else None,
}
if report is not None:
payload.update({
"completed": bool(report.completed),
"stats": report.stats.as_dict(),
"problems": list(report.problems),
})
return payload
def capture_verdict(
recorder,
*,
complete: bool,
reason: str = "",
guard: CaptureGuard | None = None,
elapsed_s: float | None = None,
timeout_s: float = CLOSING_TIMEOUT_S,
probe_video: bool = True,
source_stopped: bool = False,
) -> CaptureVerdict:
"""Decide whether the closed recording is evidence, and say why not.
``pending`` is returned while the writer is still draining, so a caller can
keep polling instead of blocking its event loop. Both recording paths pass
their own abort reason: the operator's reason is more useful than a generic
token and is what the existing single-trial contract already records.
"""
if recorder is None:
# Nothing was recorded, so nothing is evidence. A simulation and a
# missing camera both land here; the caller decides whether a capture was
# expected at all, and the reason names the case.
return CaptureVerdict(INCOMPLETE, reason or NOT_RECORDED)
report = recorder.report
if report is None:
if elapsed_s is not None and elapsed_s > float(timeout_s):
return CaptureVerdict(
INCOMPLETE, CLOSING_TIMEOUT,
report=capture_report_dict(recorder, guard),
)
return CaptureVerdict(PENDING, report=capture_report_dict(recorder, guard))
stats = report.stats
base = dict(
report=capture_report_dict(recorder, guard),
frames=stats.frames,
video_frames=stats.video_frames,
segments=stats.segments,
dropped_data=stats.dropped_data,
dropped_video=stats.dropped_video,
duration_s=stats.duration_s,
source_restarted=bool(guard is not None and guard.failed),
)
def verdict(status: str, detail: str = "", **extra) -> CaptureVerdict:
return CaptureVerdict(status, detail, **{**base, **extra})
if guard is not None and guard.failed:
return verdict(INCOMPLETE, SOURCE_RESTARTED)
if source_stopped:
# The source stopped while holds were still being recorded: the frames
# that exist stop describing the end of the repeat.
return verdict(INCOMPLETE, SOURCE_STOPPED)
if not report.completed:
return verdict(INCOMPLETE, CLOSING_TIMEOUT)
if stats.failed or stats.write_errors:
return verdict(INCOMPLETE, WRITE_ERROR)
if not complete:
return verdict(INCOMPLETE, reason or ABORTED)
if stats.frames <= 0:
return verdict(INCOMPLETE, ZERO_FRAMES)
if stats.video_frames <= 0:
return verdict(INCOMPLETE, VIDEO_MISSING)
if stats.segments != 1:
return verdict(INCOMPLETE, SEGMENT_COUNT)
if stats.dropped_data:
return verdict(INCOMPLETE, DROPPED_DATA)
if stats.dropped_video:
return verdict(INCOMPLETE, DROPPED_VIDEO)
if probe_video:
ok, detail = probe_segments(report.directory)
if not ok:
payload = dict(base["report"] or {})
payload["probe_detail"] = detail
return verdict(INCOMPLETE, VIDEO_UNDECODABLE, report=payload)
return verdict(COMPLETE)
@@ -0,0 +1,327 @@
"""Frame and force identity inside one shared-recorder directory.
A recording directory holds ``frames.csv`` (the frame index: host acquisition
time, video position, drop state) and ``forces.csv`` (the force rows). The two
are joined by ``frame_id``. A row's position in either file is not an identity:
the acquisition queue can lose a row, and a camera that restarts begins its
frame numbers again, so pairing by line number silently moves a sample to the
wrong window.
The policy for a repeated ``frame_id`` inside one directory is to **exclude**
the samples it would pair and record the reason. Refusing the whole recording
would leave an operator unable to open the evidence that shows the problem;
inventing an order instead is what this module exists to stop.
Read-only: nothing here writes, and no Qt is imported.
"""
from __future__ import annotations
import csv
import math
from dataclasses import dataclass
from pathlib import Path
#: The force components the review and the summary tables report separately.
AXES: tuple[str, ...] = ("FN", "FX", "FY")
#: Raw column per component. Newton columns are not the same measurement.
VALUE_COLUMNS: dict[str, str] = {
"FN": "FN_raw",
"FX": "FX_raw",
"FY": "FY_raw",
}
DROPPED_REASON = "dropped_row"
DUPLICATE_REASON = "duplicate_frame_identity"
def _rows(path: Path) -> list[dict]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def _text(row: dict, key: str) -> str:
return str(row.get(key) or "").strip()
def _int(row: dict, key: str) -> int | None:
"""An integer cell, parsed exactly.
A host monotonic stamp is around 1e18 ns, which a float64 cannot hold to the
nanosecond: parsing through ``float`` would map neighbouring frames onto one
timestamp and make the identity join look like it worked. Integers are read
as integers; a decimal spelling is accepted only for its integer part.
"""
text = _text(row, key)
if not text:
return None
try:
return int(text)
except ValueError:
pass
try:
return int(float(text))
except (ValueError, OverflowError):
return None
def _flag(row: dict, key: str) -> bool:
"""A 0/1 column. A missing column is *unknown*, which callers decide about."""
return _text(row, key) == "1"
def _number(row: dict, key: str) -> float | None:
text = _text(row, key)
if not text:
return None
try:
value = float(text)
except ValueError:
return None
return value if math.isfinite(value) else None
@dataclass(frozen=True)
class FrameRow:
"""One line of ``frames.csv``, before any identity decision."""
frame_id: int
t_host_mono_ns: int | None
t_rel_s: float | None
time_basis: str
segment: int
segment_frame: int
#: ``None`` when the recording predates the ``in_video`` column.
in_video: bool | None
dropped: bool
@dataclass(frozen=True)
class Sample:
"""One force row paired with the acquisition time of its frame.
``values`` is aligned to :data:`AXES`. ``None`` means the cell was empty:
an axis without a raw channel is not zero.
"""
frame_id: int
t_host_mono_ns: int
row_index: int
valid: bool
values: tuple[float | None, float | None, float | None]
def value(self, axis: str) -> float | None:
return self.values[AXES.index(axis)]
def usable(self) -> bool:
"""A valid row with at least one finite raw component."""
return self.valid and any(value is not None for value in self.values)
@dataclass(frozen=True)
class JoinReport:
"""What the join had to give up, so a summary can show it rather than hide it."""
frames_total: int = 0
frames_dropped: int = 0
frames_without_time: int = 0
frames_not_in_video: int = 0
duplicate_frame_ids: int = 0
force_rows: int = 0
unjoined_force_rows: int = 0
@property
def reasons(self) -> tuple[str, ...]:
found: list[str] = []
if self.duplicate_frame_ids:
found.append(DUPLICATE_REASON)
if self.frames_dropped:
found.append(DROPPED_REASON)
return tuple(found)
def read_frame_index(directory: Path) -> list[FrameRow]:
"""Every parseable ``frames.csv`` row, in file order.
Unparseable rows are skipped rather than sorted out: a hand-edited or
truncated index must not crash a review, and the skipped count is visible in
the index length.
"""
path = Path(directory) / "frames.csv"
if not path.is_file():
return []
has_video_column = False
frames: list[FrameRow] = []
with path.open(encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
has_video_column = reader.fieldnames is not None and "in_video" in reader.fieldnames
for row in reader:
frame_id = _int(row, "frame_id")
if frame_id is None:
continue
frames.append(
FrameRow(
frame_id=frame_id,
t_host_mono_ns=_int(row, "t_host_mono_ns"),
t_rel_s=_number(row, "t_rel_s"),
time_basis=_text(row, "time_basis"),
segment=_int(row, "segment") or 0,
segment_frame=_int(row, "segment_frame") or 0,
in_video=_flag(row, "in_video") if has_video_column else None,
dropped=_flag(row, "dropped"),
)
)
return frames
def read_force_rows(directory: Path) -> list[dict]:
path = Path(directory) / "forces.csv"
return _rows(path) if path.is_file() else []
def unique_frames(frames: list[FrameRow]) -> tuple[dict[int, FrameRow], set[int]]:
"""``(frame_id -> row, duplicated ids)``. A repeated id has no single meaning."""
index: dict[int, FrameRow] = {}
duplicated: set[int] = set()
for frame in frames:
if frame.frame_id in index:
duplicated.add(frame.frame_id)
else:
index[frame.frame_id] = frame
for frame_id in duplicated:
index.pop(frame_id, None)
return index, duplicated
def joined_samples(
directory: Path,
*,
require_video: bool = False,
) -> tuple[list[Sample], JoinReport]:
"""Pair force rows to frame host times by ``frame_id``.
``require_video=True`` keeps only frames the recorder wrote into the video
container. ``False`` keeps every non-dropped frame, which is the legacy
``stable_means`` rule: that table never filtered on video membership and its
numbers must not change for that reason alone.
"""
frames = read_frame_index(directory)
index, duplicated = unique_frames(frames)
report = JoinReport(
frames_total=len(frames),
frames_dropped=sum(1 for frame in frames if frame.dropped),
frames_without_time=sum(
1 for frame in frames if not frame.dropped and frame.t_host_mono_ns is None
),
frames_not_in_video=sum(1 for frame in frames if frame.in_video is False),
duplicate_frame_ids=len(duplicated),
)
usable: dict[int, int] = {}
for frame in index.values():
if frame.dropped or frame.t_host_mono_ns is None:
continue
if require_video and frame.in_video is False:
continue
usable[frame.frame_id] = frame.t_host_mono_ns
samples: list[Sample] = []
rows = read_force_rows(directory)
unjoined = 0
for position, row in enumerate(rows, start=1):
frame_id = _int(row, "frame_id")
stamp = None if frame_id is None else usable.get(frame_id)
if stamp is None:
unjoined += 1
continue
samples.append(
Sample(
frame_id=frame_id,
t_host_mono_ns=stamp,
row_index=position,
valid=_text(row, "valid") in ("", "1"),
values=tuple(_number(row, VALUE_COLUMNS[axis]) for axis in AXES),
)
)
samples.sort(key=lambda sample: (sample.t_host_mono_ns, sample.row_index))
return samples, JoinReport(
frames_total=report.frames_total,
frames_dropped=report.frames_dropped,
frames_without_time=report.frames_without_time,
frames_not_in_video=report.frames_not_in_video,
duplicate_frame_ids=report.duplicate_frame_ids,
force_rows=len(rows),
unjoined_force_rows=unjoined,
)
def window_samples(
samples: list[Sample], start_ns: int, end_ns: int
) -> list[Sample]:
"""Samples whose acquisition time is in ``[start_ns, end_ns)``.
Sorting happens in :func:`joined_samples`, so a caller may bisect; a scan is
used here because a window is a small slice of the recording.
"""
return [sample for sample in samples if start_ns <= sample.t_host_mono_ns < end_ns]
def resolve_capture_directory(
root: Path, *, trial_directory: str, frame_source: str = ""
) -> Path | None:
"""Where a trial's frame files are: the trial directory or its ``capture/``.
The single-trial format keeps them beside ``reference.csv``; the multi-point
format nests them. The review already records the answer in
``frame_source``, and this function is the one place that falls back.
"""
root = Path(root)
source = str(frame_source or trial_directory or "")
if not source:
return None
candidate = (root / source).resolve()
if not candidate.is_relative_to(root.resolve()):
return None
if candidate.is_dir() and (candidate / "frames.csv").is_file():
return candidate
nested = candidate / "capture"
if (nested / "frames.csv").is_file():
return nested
return None
def reference_holds(directory: Path) -> list[dict]:
"""Every declared hold attempt in `reference.csv`, with its row ordinal.
`hold_id` is the data row's position, not a hash of the file: the remeasure
path rewrites the file in place (one flag changes), so a hash would change the
identity of a hold whose window did not move. A row whose times are unusable
is not a window and is skipped.
"""
import csv as _csv
path = Path(directory) / "reference.csv"
if not path.is_file():
return []
holds: list[dict] = []
with path.open(encoding="utf-8-sig", newline="") as handle:
for ordinal, row in enumerate(_csv.DictReader(handle), start=1):
start = _number(row, "t_start_host_mono_ns")
end = _number(row, "t_end_host_mono_ns")
if start is None or end is None or end <= start:
continue
step = row.get("step_index")
try:
step_index = int(float(step))
except (TypeError, ValueError):
step_index = -1
holds.append({
"hold_id": ordinal,
"step_index": step_index,
"branch": str(row.get("branch") or ""),
"target_load_n": _number(row, "target_load_n"),
"reference_load_n": _number(row, "F_ref_normal_N"),
"reference_source": str(row.get("reference_source") or ""),
"t_start_host_mono_ns": int(start),
"t_end_host_mono_ns": int(end),
"complete": str(row.get("hold_complete") or "0").strip() == "1",
})
return holds
+377 -72
View File
@@ -28,6 +28,16 @@ from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from .calibration_session import write_json from .calibration_session import write_json
from .capture_guard import (
CLOSING_TIMEOUT_S,
COMPLETE,
INCOMPLETE,
PENDING,
CaptureGuard,
NOT_RECORDED,
capture_verdict,
)
from .frames_index import read_frame_index, unique_frames
from .sensor.frames import PLATFORM_AXES, TOOL_AXIS from .sensor.frames import PLATFORM_AXES, TOOL_AXIS
SENSOR_MODEL = "finger" SENSOR_MODEL = "finger"
@@ -245,27 +255,29 @@ def stable_component_rows(
return [] return []
with reference_path.open(encoding="utf-8-sig", newline="") as handle: with reference_path.open(encoding="utf-8-sig", newline="") as handle:
holds = list(csv.DictReader(handle)) holds = list(csv.DictReader(handle))
stamps: list[int | None] = [] # Frames and forces meet by frame_id, never by line number: a dropped row or
frames_path = repeat_dir / "capture" / "frames.csv" # a camera that restarted would otherwise move a sample to another hold.
if frames_path.is_file(): # A repeated frame_id has no single time, so its samples are left out rather
with frames_path.open(encoding="utf-8-sig", newline="") as handle: # than paired with an order this file cannot prove.
for row in csv.DictReader(handle): capture_dir = repeat_dir / "capture"
# A gap is a hole in the force file, not a sample. A camera that frame_rows = read_frame_index(capture_dir)
# is plugged back in starts its frame numbers again, so the time unique, _duplicated = unique_frames(frame_rows)
# of a sample is the frame written with it, not the frame number. stamps: dict[int, int] = {
if row.get("dropped") not in (None, "", "0"): frame_id: row.t_host_mono_ns
continue for frame_id, row in unique.items()
text = str(row.get("t_host_mono_ns") or "").strip() if not row.dropped and row.t_host_mono_ns is not None
stamps.append(int(text) if text else None) }
samples: list[tuple[int, str, tuple[float, float, float]]] = [] samples: list[tuple[int, str, tuple[float, float, float]]] = []
forces_path = repeat_dir / "capture" / "forces.csv" forces_path = capture_dir / "forces.csv"
if forces_path.is_file(): if forces_path.is_file():
with forces_path.open(encoding="utf-8-sig", newline="") as handle: with forces_path.open(encoding="utf-8-sig", newline="") as handle:
for index, row in enumerate(csv.DictReader(handle)): for row in csv.DictReader(handle):
if index >= len(stamps):
break
components = _components(row) components = _components(row)
stamp = stamps[index] try:
frame_id = int(str(row.get("frame_id") or "").strip())
except ValueError:
continue
stamp = stamps.get(frame_id)
if components is None or stamp is None: if components is None or stamp is None:
continue continue
samples.append((stamp, components[0], components[1])) samples.append((stamp, components[0], components[1]))
@@ -422,11 +434,33 @@ class Campaign:
self._pending: dict | None = None self._pending: dict | None = None
self._single_site = False self._single_site = False
self._finished_step_pending = False self._finished_step_pending = False
#: The repeat record being recorded. Identity is carried explicitly: a
#: new repeat must never be described by ``trials[-1]``, which still
#: names the previous one while the new directory is being prepared.
self._trial_entry: dict | None = None
self._capture_session_id: int | None = None
self._guard: CaptureGuard | None = None
#: Closing state. A repeat is not complete until the writer has closed
#: its files and the report has been checked.
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report: dict | None = None
self._closing_entry: dict | None = None
self._closing_recorder = None
self._closing_guard: CaptureGuard | None = None
self._closing_complete = False
self._closing_terminal = False
self._closing_since: float | None = None
@property @property
def is_active(self) -> bool: def is_active(self) -> bool:
return self.phase in ACTIVE return self.phase in ACTIVE
@property
def busy(self) -> bool:
"""Active, or still closing the evidence of the repeat that just ended."""
return self.is_active or self.capture_state == "closing" or self.phase == "closing"
def add_site( def add_site(
self, self,
site_id: str, site_id: str,
@@ -581,7 +615,7 @@ class Campaign:
simulate: bool, simulate: bool,
min_force_n: float = 0.0, min_force_n: float = 0.0,
) -> Path: ) -> Path:
if self.is_active: if self.is_active or self.capture_state == "closing" or self.phase == "closing":
raise RuntimeError("a multi-point acquisition is already running") raise RuntimeError("a multi-point acquisition is already running")
if sensor_model.strip() != SENSOR_MODEL: if sensor_model.strip() != SENSOR_MODEL:
raise ValueError("multi-point acquisition uses the finger product model") raise ValueError("multi-point acquisition uses the finger product model")
@@ -610,6 +644,30 @@ class Campaign:
self._finished_step_pending = False self._finished_step_pending = False
self._completed_holds = 0 self._completed_holds = 0
self._trial_serial = 0 self._trial_serial = 0
# A new session must not inherit anything from the previous one. The
# worker's record sinks outlive its own ``stop()``/``start()``, and the
# force-to-tool rotation belonged to a session that has ended.
self._pending_worker = None
self._image_worker = None
self._recorder = None
self._guard = None
self._capture_session_id = None
self._fps = 0.0
self._rotation = None
self._active = None
self._pending = None
self._hold_started_ns = None
self._trial_dir = None
self._trial_entry = None
self._source_stopped = False
self._closing_entry = None
self._closing_recorder = None
self._closing_complete = False
self._closing_terminal = False
self._closing_since = None
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report = None
self.directory = _session_directory(Path(root), self.sensor_model, self.sensor_sn) self.directory = _session_directory(Path(root), self.sensor_model, self.sensor_sn)
self._document = { self._document = {
"format_version": "autocal-multipoint-1.0", "format_version": "autocal-multipoint-1.0",
@@ -726,25 +784,7 @@ class Campaign:
self.phase = "load" self.phase = "load"
return return
self._finished_step_pending = False self._finished_step_pending = False
self._close_repeat(complete=True) self._begin_close(complete=True)
self._repeat_index += 1
if self._single_site:
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": ""})
return
site = self.sites[self._site_index]
if self._repeat_index < site.repeats:
self._open_repeat()
self.phase = "load"
return
self._site_index += 1
self._repeat_index = 0
if self._site_index < len(self.sites):
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": self.sites[self._site_index].site_id})
return
self._finish_session("complete")
self.phase = "done"
def confirm_liftoff(self) -> None: def confirm_liftoff(self) -> None:
if self.phase != "liftoff": if self.phase != "liftoff":
@@ -766,6 +806,8 @@ class Campaign:
self.phase = "load" self.phase = "load"
def stop(self, reason: str) -> None: def stop(self, reason: str) -> None:
if self.phase == "closing" or self.capture_state == "closing":
return
if not self.is_active: if not self.is_active:
return return
self.reason = reason or "stopped" self.reason = reason or "stopped"
@@ -778,8 +820,10 @@ class Campaign:
self._reference_file.flush() self._reference_file.flush()
self._active = None self._active = None
self._pending = None self._pending = None
if self._trial_dir is not None: self._hold_started_ns = None
self._close_repeat(complete=False) if self._trial_entry is not None or self._recorder is not None:
self._begin_close(complete=False, terminal=True)
else:
self._finish_session("incomplete") self._finish_session("incomplete")
self.phase = "aborted" self.phase = "aborted"
@@ -913,10 +957,11 @@ class Campaign:
"total": self._total_holds(), "total": self._total_holds(),
"directory": str(self.directory) if self.directory else "", "directory": str(self.directory) if self.directory else "",
"reason": self.reason, "reason": self.reason,
"capture": self.capture_summary(),
} }
def attach_capture(self, worker, *, fps: float) -> None: def attach_capture(self, worker, *, fps: float) -> None:
"""Record images into the open trial. Does not move the platform.""" """Record images into the open repeat. Does not move the platform."""
if self._trial_dir is None: if self._trial_dir is None:
raise RuntimeError("no trial is open") raise RuntimeError("no trial is open")
packet = None if worker is None else worker.latest() packet = None if worker is None else worker.latest()
@@ -924,7 +969,7 @@ class Campaign:
raise RuntimeError("start the camera and wait for a frame before live capture") raise RuntimeError("start the camera and wait for a frame before live capture")
self._pending_worker = worker self._pending_worker = worker
self._fps = float(fps) self._fps = float(fps)
self._start_images(packet) self._start_images(worker, self._trial_entry)
def _spec(self, site_id, x, y, z, levels, hold_s, repeats, max_force_n, min_force_n: float = 0.0) -> SiteSpec: def _spec(self, site_id, x, y, z, levels, hold_s, repeats, max_force_n, min_force_n: float = 0.0) -> SiteSpec:
hold = _finite(hold_s, "hold time") hold = _finite(hold_s, "hold time")
@@ -978,6 +1023,13 @@ class Campaign:
) )
def _open_repeat(self) -> None: def _open_repeat(self) -> None:
"""Register the repeat first, then attach the recorder to that record.
The order is the contract: an explicit trial record exists before any
file is written for it, and capture is started *with* that record. The
previous implementation started the recorder first and then updated
``trials[-1]``, which still named the repeat that had just closed.
"""
site = self.sites[self._site_index] site = self.sites[self._site_index]
self._steps = self._steps_for(site) self._steps = self._steps_for(site)
self._step_index = 0 self._step_index = 0
@@ -987,8 +1039,10 @@ class Campaign:
folder = _path_part(site.site_id) folder = _path_part(site.site_id)
trial_id = f"{folder}-repeat-{repeat_number:03d}" trial_id = f"{folder}-repeat-{repeat_number:03d}"
directory = self.directory / "sites" / folder / f"repeat-{repeat_number:03d}" directory = self.directory / "sites" / folder / f"repeat-{repeat_number:03d}"
if directory.exists():
# A directory still being written must never be reused for a new attempt.
raise RuntimeError(f"the repeat directory already exists: {directory.name}")
directory.mkdir(parents=True) directory.mkdir(parents=True)
self._trial_dir = directory
metadata = { metadata = {
"trial_id": trial_id, "trial_id": trial_id,
"site_id": site.site_id, "site_id": site.site_id,
@@ -1013,9 +1067,30 @@ class Campaign:
"captured": False, "captured": False,
"reason": "simulation does not open a camera" if self.simulate else "camera not attached yet", "reason": "simulation does not open a camera" if self.simulate else "camera not attached yet",
}, },
"capture_status": "idle",
"capture_reason": "",
"status": "incomplete", "status": "incomplete",
"reason": "repeat has not finished",
} }
write_json(directory / "session.json", metadata) write_json(directory / "session.json", metadata)
entry = {
"trial_id": trial_id,
"site_id": site.site_id,
"repeat_index": repeat_number,
"directory": f"sites/{folder}/repeat-{repeat_number:03d}",
"status": "incomplete",
"reason": "repeat has not finished",
"images": dict(metadata["images"]),
"capture_status": "idle",
"capture_reason": "",
}
self._document["trials"].append(entry)
self._trial_dir = directory
self._trial_entry = entry
self._source_stopped = False
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report = None
reference_file = (directory / "reference.csv").open("w", newline="", encoding="utf-8") reference_file = (directory / "reference.csv").open("w", newline="", encoding="utf-8")
writer = csv.writer(reference_file) writer = csv.writer(reference_file)
writer.writerow(REFERENCE_HEADER) writer.writerow(REFERENCE_HEADER)
@@ -1028,38 +1103,253 @@ class Campaign:
events.flush() events.flush()
self._events_file = events self._events_file = events
self._events = event_writer self._events = event_writer
self._event("trial_started", {"site_id": site.site_id, "repeat_index": self._repeat_index + 1})
if self._pending_worker is not None:
self._start_images(self._pending_worker.latest())
self._document["trials"].append({
"trial_id": trial_id,
"site_id": site.site_id,
"repeat_index": self._repeat_index + 1,
"directory": f"sites/{folder}/repeat-{repeat_number:03d}",
"status": "incomplete",
})
self._save() self._save()
self._event("trial_started", {"site_id": site.site_id, "repeat_index": repeat_number})
if self._pending_worker is not None:
self._start_images(self._pending_worker, entry)
def _close_repeat(self, *, complete: bool) -> None: def _write_repeat_manifest(
self._release_images() self, entry: dict, *, status: str, capture_status: str
) -> None:
"""Update one repeat's own manifest. Never rebuild it from memory."""
path = self.directory / entry["directory"] / "session.json"
try:
metadata = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
metadata = {}
metadata.update({
"status": status,
"reason": entry.get("reason", ""),
"capture_status": capture_status,
"capture_reason": entry.get("capture_reason", ""),
"images": entry.get("images") or metadata.get("images"),
})
if entry.get("capture_report") is not None:
metadata["capture_report"] = entry["capture_report"]
write_json(path, metadata)
def _close_repeat_files(self) -> None:
if self._reference_file is not None: if self._reference_file is not None:
try:
self._reference_file.close() self._reference_file.close()
finally:
self._reference_file = None self._reference_file = None
if self._events_file is not None: if self._events_file is not None:
try:
self._events_file.close() self._events_file.close()
finally:
self._events_file = None self._events_file = None
self._events = None self._events = None
if self._trial_dir is None:
return
path = self._trial_dir / "session.json"
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["status"] = "complete" if complete else "incomplete"
metadata["reason"] = "" if complete else self.reason
write_json(path, metadata)
self._document["trials"][-1]["status"] = metadata["status"]
self._document["trials"][-1]["reason"] = metadata["reason"]
self._trial_dir = None self._trial_dir = None
self._trial_entry = None
def _begin_close(self, *, complete: bool, terminal: bool = False) -> None:
"""Stop the recorder without waiting for it, then let :meth:`poll` finish.
The Qt thread cannot sit in ``recorder.stop(wait=True)``: the writer may
need seconds to flush a video, and the operator has to keep seeing the
window. The repeat's own record is written again with the final status
only after the report exists, so an interrupted close leaves an
explicitly incomplete manifest rather than a false complete one.
"""
entry = self._trial_entry
if entry is None and self._recorder is None:
if terminal:
self._finish_session("incomplete")
self.phase = "aborted"
return
self._closing_entry = entry
self._closing_complete = bool(complete)
self._closing_terminal = bool(terminal)
recorder, guard, worker = self._recorder, self._guard, self._image_worker
self._recorder = None
self._guard = None
self._image_worker = None
self._closing_guard = guard
if worker is not None and guard is not None:
worker.remove_sink(guard)
self._close_repeat_files()
if recorder is not None:
recorder.stop(wait=False)
self._closing_recorder = recorder
self._closing_since = time.perf_counter()
self.capture_state = "closing"
self.capture_reason = ""
if entry is not None:
entry["capture_status"] = "closing"
self._write_repeat_manifest(entry, status="incomplete", capture_status="closing")
self._save() self._save()
if recorder is None:
self._finalise_close()
else:
# The acquisition step is over even though the writer is still
# draining: a second confirmation must not start another hold.
self.phase = "closing"
def _finalise_close(self) -> None:
entry = self._closing_entry
if entry is None:
self.capture_state = "idle"
return
recorder = self._closing_recorder
if recorder is None and self.simulate:
# A simulated campaign is not a recording: its steps can be complete,
# but there is no capture evidence to call complete.
self._finish_repeat(
entry,
status=COMPLETE if self._closing_complete else INCOMPLETE,
capture_status=NOT_RECORDED,
capture_reason=NOT_RECORDED,
report=None,
state=NOT_RECORDED,
)
return
elapsed = None if self._closing_since is None else time.perf_counter() - self._closing_since
verdict = capture_verdict(
recorder,
complete=self._closing_complete,
reason=self.reason,
guard=self._closing_guard,
elapsed_s=elapsed,
source_stopped=self._source_stopped,
)
if verdict.status == PENDING:
self.capture_state = "closing"
return
status = COMPLETE if (
self._closing_complete and verdict.status == COMPLETE
) else INCOMPLETE
self._finish_repeat(
entry,
status=status,
capture_status=verdict.status,
capture_reason=verdict.reason,
report=verdict.report,
state=verdict.status,
)
def _finish_repeat(
self,
entry: dict,
*,
status: str,
capture_status: str,
capture_reason: str,
report: dict | None,
state: str,
) -> None:
"""Write the repeat's final record, then move the campaign on."""
entry["status"] = status
entry["reason"] = "" if status == COMPLETE else (self.reason or capture_reason)
entry["capture_status"] = capture_status
entry["capture_reason"] = capture_reason
entry["capture_report"] = report
try:
self._write_repeat_manifest(entry, status=status, capture_status=capture_status)
except OSError:
entry["status"] = INCOMPLETE
entry["reason"] = "could not write the repeat manifest"
self.capture_state = state
self.capture_reason = capture_reason
self.capture_report = report
self._source_stopped = False
self._closing_entry = None
self._closing_recorder = None
self._closing_guard = None
self._closing_since = None
self._save()
if self._closing_terminal:
self._finish_session("incomplete")
self.phase = "aborted"
return
self._advance_after_repeat()
def _advance_after_repeat(self) -> None:
"""Move to the next repeat, the next point, or the end of the campaign."""
self._repeat_index += 1
if self._single_site:
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": ""})
return
site = self.sites[self._site_index]
if self._repeat_index < site.repeats:
self._open_repeat()
self.phase = "load"
return
self._site_index += 1
self._repeat_index = 0
if self._site_index < len(self.sites):
self.phase = "liftoff"
self._event("liftoff_required", {"next_site_id": self.sites[self._site_index].site_id})
return
self._finish_session(self._session_status())
self.phase = "done"
def poll(self) -> dict:
"""Drive a closing writer and report the capture state. Never blocks."""
guard = self._guard
if guard is not None and self.phase in {"load", "hold"}:
worker = self._image_worker
if guard.failed:
self.stop("the image source restarted")
elif worker is not None and not getattr(worker, "is_running", True):
# The camera went away mid-repeat; the frames already written no
# longer describe the holds that follow, so the repeat cannot be
# called complete evidence.
self._source_stopped = True
self.stop("the image source stopped")
if self.capture_state == "closing":
self._finalise_close()
return {
"phase": self.phase,
"capture_state": self.capture_state,
"capture_reason": self.capture_reason,
}
def flush_close(self, *, timeout: float = CLOSING_TIMEOUT_S) -> None:
"""Bounded wait used only when the window is closing."""
if self.capture_state != "closing":
return
if self._closing_recorder is not None:
self._closing_recorder.stop(wait=True, timeout=timeout)
self._finalise_close()
@property
def closing(self) -> bool:
return self.capture_state == "closing"
@property
def capture_ready(self) -> bool:
"""Whether a hold may be confirmed for the open repeat."""
if self._trial_entry is None:
return False
if self.simulate:
return True
return (
self.capture_state == "recording"
and self._guard is not None
and not self._guard.failed
)
def capture_summary(self) -> dict:
"""What the operator needs about the current recording, without reading disk."""
recorder = self._recorder or self._closing_recorder
stats = recorder.stats() if recorder is not None else None
entry = self._trial_entry or self._closing_entry
return {
"state": self.capture_state,
"reason": self.capture_reason,
"ready": self.capture_ready,
"frames": int(getattr(stats, "frames", 0) or 0),
"video_frames": int(getattr(stats, "video_frames", 0) or 0),
"dropped_data": int(getattr(stats, "dropped_data", 0) or 0),
"dropped_video": int(getattr(stats, "dropped_video", 0) or 0),
"duration_s": float(getattr(stats, "duration_s", 0.0) or 0.0),
"directory": str(self.directory / entry["directory"]) if (
entry is not None and self.directory is not None
) else "",
"trial_id": str(entry.get("trial_id", "")) if entry is not None else "",
}
def _finish_session(self, status: str) -> None: def _finish_session(self, status: str) -> None:
self._document["status"] = status self._document["status"] = status
@@ -1163,12 +1453,18 @@ class Campaign:
self._events.writerow(row) self._events.writerow(row)
self._events_file.flush() self._events_file.flush()
def _start_images(self, packet) -> None: def _start_images(self, worker, entry: dict | None = None) -> None:
"""Start the recorder **for the repeat that is open**, with its identity."""
entry = self._trial_entry if entry is None else entry
if entry is None or entry is not self._trial_entry:
raise RuntimeError("start capture for the repeat that is open")
packet = None if worker is None else worker.latest()
if packet is None or packet.capture_mono_ns is None: if packet is None or packet.capture_mono_ns is None:
raise RuntimeError("start the camera and wait for a frame before live capture") raise RuntimeError("start the camera and wait for a frame before live capture")
if self._recorder is not None:
raise RuntimeError("a capture is already running for this repeat")
from orisys_app_shared.record import ExperimentRecorder, RecordConfig from orisys_app_shared.record import ExperimentRecorder, RecordConfig
self._release_images()
recorder = ExperimentRecorder( recorder = ExperimentRecorder(
RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True) RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True)
) )
@@ -1178,19 +1474,28 @@ class Campaign:
metadata={"sync": SYNC, "images_only": True}, metadata={"sync": SYNC, "images_only": True},
directory_name="capture", directory_name="capture",
) )
self._pending_worker.add_sink(recorder.on_packet) guard = CaptureGuard(expected_session_id=int(packet.session_id), recorder=recorder)
worker.add_sink(guard)
self._recorder = recorder self._recorder = recorder
self._image_worker = self._pending_worker self._guard = guard
trial = self._document["trials"][-1] self._image_worker = worker
trial["images"] = {"captured": True, "directory": f"{trial['directory']}/capture"} self._capture_session_id = int(packet.session_id)
self.capture_state = "recording"
self.capture_reason = ""
entry["images"] = {"captured": True, "directory": f"{entry['directory']}/capture"}
entry["capture_session_id"] = self._capture_session_id
entry["capture_status"] = "recording"
self._write_repeat_manifest(entry, status="incomplete", capture_status="recording")
self._save() self._save()
def _release_images(self) -> None: def _release_images(self) -> None:
recorder, worker = self._recorder, self._image_worker """Stop the capture synchronously. Used only by the bounded shutdown path."""
recorder, guard, worker = self._recorder, self._guard, self._image_worker
self._recorder = None self._recorder = None
self._guard = None
self._image_worker = None self._image_worker = None
if worker is not None and recorder is not None: if worker is not None and guard is not None:
worker.remove_sink(recorder.on_packet) worker.remove_sink(guard)
if recorder is not None: if recorder is not None:
recorder.stop(wait=True) recorder.stop(wait=True)
@@ -14,6 +14,7 @@ from uuid import uuid4
import numpy as np import numpy as np
from .calibration_session import write_json from .calibration_session import write_json
from .frames_index import read_frame_index, unique_frames
def _rows(path: Path) -> list[dict]: def _rows(path: Path) -> list[dict]:
@@ -158,25 +159,27 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
bases: set[str] = set() bases: set[str] = set()
in_video = 0 in_video = 0
segments: set[int] = set() segments: set[int] = set()
for row in _rows(directory / "frames.csv"): frame_rows = read_frame_index(directory)
if row.get("dropped") != "0" or not row.get("t_host_mono_ns"): unique, duplicated = unique_frames(frame_rows)
if duplicated:
# A repeated frame number has no single acquisition time. Its samples are
# left out instead of being paired with an order the files cannot prove.
source["duplicate_frame_ids"] = len(duplicated)
for frame in frame_rows:
if frame.dropped or frame.t_host_mono_ns is None or frame.in_video is False:
continue continue
if row.get("in_video") != "1": if frame.frame_id in duplicated:
continue continue
segment = int(row["segment"]) segment = frame.segment
video = directory / f"raw_{segment:04d}.mp4" video = directory / f"raw_{segment:04d}.mp4"
if segment < 1 or not video.is_file() or video.stat().st_size == 0: if segment < 1 or not video.is_file() or video.stat().st_size == 0:
source["reason"] = "missing_video_segment" source["reason"] = "missing_video_segment"
return source return source
frame_id = int(row["frame_id"]) frames[frame.frame_id] = frame.t_host_mono_ns
if frame_id in frames: bases.add(frame.time_basis)
raise ValueError("duplicate frame identity in a recorded frame source")
frames[frame_id] = int(row["t_host_mono_ns"])
bases.add(row.get("time_basis", ""))
if row.get("in_video") == "1":
in_video += 1 in_video += 1
if row.get("segment"): if segment:
segments.add(int(row["segment"])) segments.add(segment)
if not frames: if not frames:
source["reason"] = "no_recorded_video_frames" source["reason"] = "no_recorded_video_frames"
return source return source
@@ -268,6 +271,11 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
issues.append({"trial_id": trial_id, "reason": "incomplete_trial", issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
"detail": str(trial.get("reason") or record.get("status") or "")}) "detail": str(trial.get("reason") or record.get("status") or "")})
source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs) source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs)
if source.get("duplicate_frame_ids"):
issues.append({
"trial_id": trial_id, "reason": "duplicate_frame_identity",
"detail": f"{source['duplicate_frame_ids']} frame id(s) repeated",
})
samples = source["samples"] samples = source["samples"]
unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS] unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS]
if not source["recorded"]: if not source["recorded"]:
@@ -435,13 +443,16 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
or metadata.get("uv_definition") != manifest["uv_definition"]): or metadata.get("uv_definition") != manifest["uv_definition"]):
raise ValueError(f"trial identity or completion mismatch: {trial_id}") raise ValueError(f"trial identity or completion mismatch: {trial_id}")
frames = {} frames = {}
for row in _rows(path / "frames.csv"): unique, duplicated = unique_frames(read_frame_index(path))
if row["dropped"] != "0" or not row["t_host_mono_ns"]: if duplicated:
issues.append({
"trial_id": trial_id, "reason": "duplicate_frame_identity",
"detail": f"{len(duplicated)} frame id(s) repeated",
})
for frame_id, frame in unique.items():
if frame.dropped or frame.t_host_mono_ns is None:
continue continue
frame_id = int(row["frame_id"]) frames[frame_id] = frame.t_host_mono_ns
if frame_id in frames:
raise ValueError(f"duplicate frame identity in {trial_id}")
frames[frame_id] = int(row["t_host_mono_ns"])
samples = [] samples = []
for row in _rows(path / "forces.csv"): for row in _rows(path / "forces.csv"):
if row["valid"] != "1" or not row["FN_raw"]: if row["valid"] != "1" or not row["FN_raw"]:
+655
View File
@@ -0,0 +1,655 @@
"""Operator summary: two traceable tables built from recorded evidence.
The summary answers the operator's question — what did each measured point do
under each declared load — without becoming a second calibration. It is built
from the review's hold windows (one definition of "an attempt") plus the raw
components in the frame source, joined by frame identity.
Two tables, both versioned:
``baseline_load_data.csv``
one row per actual hold attempt, with its identity, window method, sample
counts and the three raw component means. A rejected attempt stays visible.
``sensitivity_values.csv``
one row per point, trial, axis and review set, with the forward regression
``raw = a * reference + b``. ``a`` is not ``1 / gain`` of the review's inverse
fit; the two answer different questions and are kept apart by name.
Nothing here writes files (see :mod:`summary_export`) and nothing calls the SDK.
Method and unit rules, fixed here so a reader can recompute every number:
* window: ``hold_second_half_v1`` for an unverified candidate, the reviewer's
exact interval (``review_selection_v1``) once confirmed. The latter half is the
settling-time rule the legacy ``stable_means.csv`` already used; no auto
"stable" flag is ever written.
* samples: raw components only. A missing cell stays empty; no zero is invented
and no Newton value is mixed in. Standard deviation is the sample deviation
(``ddof=1``); fewer than two samples leave it empty.
* fit: ordinary least squares of ``raw`` on the *actual* reference N, within one
trial, over zero-load and load windows at equal weight. Unload windows are
reported (they show hysteresis) but never enter this fit. At least two
distinct reference loads are required.
"""
from __future__ import annotations
import csv
import hashlib
import json
import math
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from uuid import uuid4
import numpy as np
from .frames_index import (
AXES,
joined_samples,
reference_holds,
resolve_capture_directory,
)
from .session_review import review_session
SCHEMA_VERSION = "autocal-summary-1.0"
HOLD_WINDOW_METHOD = "hold_second_half_v1"
REVIEW_WINDOW_METHOD = "review_selection_v1"
LEGACY_WINDOW_METHOD = "legacy_stable_means_v1"
FIT_METHOD = "raw_on_reference_ols_v1"
UNIT = "raw"
FORMAL_CONCLUSION = False
#: Reasons a row can be listed but not used. Tokens, localised by the report.
REASON_INCOMPLETE_TRIAL = "incomplete_trial"
REASON_NO_FRAME_SOURCE = "no_frame_source"
REASON_CAPTURE_INCOMPLETE = "capture_incomplete"
REASON_NOT_RECORDED = "not_recorded"
REASON_TIME_BASIS = "time_basis_not_comparable"
REASON_SIMULATION = "simulation"
REASON_REMEASURED = "remeasured"
REASON_INCOMPLETE_HOLD = "incomplete_hold"
REASON_INSUFFICIENT_SAMPLES = "insufficient_samples"
REASON_LEGACY = "legacy_no_window"
REASON_NO_DATA = "no_data"
REASON_FEWER_LEVELS = "insufficient_reference_levels"
REASON_FLAT = "flat_signal"
REASON_NO_ZERO = "missing_zero_load"
REASON_NO_HIGH = "missing_highest_load"
#: Review window statuses that mean "the evidence itself is missing".
_BLOCKING_STATUSES = {
"no_frame_source", "time_basis_not_comparable", "simulation",
"incomplete_trial", "insufficient_samples",
}
#: Capture lifecycles that mean "there is no usable video for this repeat".
_BLOCKING_CAPTURE = {"incomplete", "missing", "not_recorded"}
def _number(value) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _selections_hash(selections: dict) -> str:
return _sha256_text(json.dumps(selections, sort_keys=True, ensure_ascii=False))
def _versions() -> dict:
found = {}
for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"):
try:
found[package] = version(package)
except PackageNotFoundError:
found[package] = "unavailable"
return found
def _capture_directory(root: Path, trial: dict, window: dict) -> Path | None:
return resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
def _trial_by_id(review: dict) -> dict[str, dict]:
return {trial["trial_id"]: trial for trial in review["trials"]}
def _capture_status(root: Path, trial: dict) -> tuple[str, str]:
"""``(status, reason)`` from the repeat record, defaulting to unknown.
The capture lifecycle is written into the repeat's own ``session.json`` (the
root manifest entry carries only what the campaign knew at the time), so the
repeat record is read when the entry has nothing newer. A legacy record with
no lifecycle field at all is *unknown*: ``images.captured`` says a recorder
was attached once, not that the video is usable.
"""
status = str(trial.get("capture_status") or "").strip()
if status:
return status, str(trial.get("capture_reason") or "")
record: dict = {}
path = root / str(trial.get("directory") or "") / "session.json"
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
record = {}
status = str(record.get("capture_status") or "").strip()
if status:
return status, str(record.get("capture_reason") or "")
images = trial.get("images") or record.get("images") or {}
if images.get("captured") is True:
return "unknown", ""
return "missing", str(images.get("reason") or "")
def _axis_stats(values_by_axis: dict[str, list[float]]) -> dict:
result = {}
for axis in AXES:
values = [value for value in values_by_axis[axis] if value is not None]
result[axis] = {
"n": len(values),
"mean": float(np.mean(values)) if values else None,
"std": float(np.std(values, ddof=1)) if len(values) >= 2 else None,
"min": float(np.min(values)) if values else None,
"max": float(np.max(values)) if values else None,
}
return result
def _window_interval(window: dict, hold: dict | None) -> tuple[int, int, str]:
"""The interval the summary averages, and the method that chose it."""
if window.get("stability_verified"):
return (
int(window["t_start_host_mono_ns"]),
int(window["t_end_host_mono_ns"]),
REVIEW_WINDOW_METHOD,
)
start = int(window["hold_start_host_mono_ns"])
end = int(window["hold_end_host_mono_ns"])
if hold is not None:
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
return start + (end - start) // 2, end, HOLD_WINDOW_METHOD
def _frame_samples(
cache: dict[Path, object], directory: Path, *, require_video: bool
) -> list:
key = (directory, require_video)
if key not in cache:
cache[key] = joined_samples(directory, require_video=require_video)[0]
return cache[key]
def _window_samples(
cache: dict, directory: Path, *, require_video: bool, start_ns: int, end_ns: int
) -> tuple[dict[str, list[float]], int, int]:
values: dict[str, list[float]] = {axis: [] for axis in AXES}
total = 0
excluded = 0
for sample in _frame_samples(cache, directory, require_video=require_video):
if not start_ns <= sample.t_host_mono_ns < end_ns:
continue
total += 1
if not sample.valid:
excluded += 1
continue
for axis in AXES:
values[axis].append(sample.value(axis))
return values, total, excluded
def _excluded_reason(window: dict, trial_status: str, capture: tuple[str, str]) -> str:
status = str(window.get("status") or "")
if trial_status != "complete":
return REASON_INCOMPLETE_TRIAL
# The review's own status is more specific than the capture lifecycle: a
# simulated campaign reports "no frame source", not "capture incomplete".
if status in _BLOCKING_STATUSES:
return status
if capture[0] in _BLOCKING_CAPTURE:
if capture[0] == "not_recorded":
return REASON_NOT_RECORDED
if capture[0] == "missing":
return REASON_NO_FRAME_SOURCE
return REASON_CAPTURE_INCOMPLETE
if status != "candidate":
return str(status or REASON_NO_DATA)
return ""
def build_windows(root: Path, review: dict) -> list[dict]:
"""One row per actual hold attempt, including the ones that were replaced."""
root = Path(root).resolve()
trials = _trial_by_id(review)
by_trial_holds = {
trial_id: reference_holds(root / trial["directory"])
for trial_id, trial in trials.items()
}
windows_by_trial: dict[str, list[dict]] = {}
for site in review["sites"]:
for window in site["windows"]:
windows_by_trial.setdefault(window["trial_id"], []).append(window)
cache: dict = {}
rows: list[dict] = []
for trial in review["trials"]:
trial_id = trial["trial_id"]
trial_status = str(trial.get("status") or "")
capture = _capture_status(root, trial)
holds = by_trial_holds.get(trial_id, [])
windows = windows_by_trial.get(trial_id, [])
matched: set[tuple[int, int]] = set()
for hold in holds:
window = next(
(
item for item in windows
if item["step_index"] == hold["step_index"]
and int(item["hold_start_host_mono_ns"]) == hold["t_start_host_mono_ns"]
),
None,
)
if window is not None:
matched.add((hold["step_index"], hold["t_start_host_mono_ns"]))
directory = _capture_directory(root, trial, window or {})
require_video = bool(review.get("source_format"))
if window is not None and hold["complete"]:
start, end, method = _window_interval(window, hold)
if directory is None:
values = {axis: [] for axis in AXES}
total = excluded = 0
else:
values, total, excluded = _window_samples(
cache, directory, require_video=require_video,
start_ns=start, end_ns=end,
)
else:
values = {axis: [] for axis in AXES}
total = excluded = 0
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
method = HOLD_WINDOW_METHOD
stats = _axis_stats(values)
window_status = str(window.get("status", "") if window is not None else "")
verified = bool(window.get("stability_verified")) if window is not None else False
if not hold["complete"]:
reason = REASON_REMEASURED if any(
other["step_index"] == hold["step_index"] and other["complete"]
for other in holds
) else REASON_INCOMPLETE_HOLD
elif window is None:
reason = REASON_INCOMPLETE_TRIAL if trial_status != "complete" else REASON_NO_DATA
elif not verified and window_status == "candidate" and stats["FN"]["n"] < 2:
reason = REASON_INSUFFICIENT_SAMPLES
else:
reason = _excluded_reason(window, trial_status, capture)
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": "", # filled by build_summary
"session_id": review["session_id"],
"trial_id": trial_id,
"hold_id": hold["hold_id"],
"site_id": str(trial.get("site_id") or ""),
"point": "",
"repeat_index": trial.get("repeat_index"),
"step_index": hold["step_index"],
"branch": hold["branch"],
"source_file": f"{trial['directory']}/reference.csv",
"U": None,
"V": None,
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
"reference_source": hold["reference_source"],
"target_load_n": hold["target_load_n"],
"reference_load_n": hold["reference_load_n"],
"t_start_host_mono_ns": start,
"t_end_host_mono_ns": end,
"time_basis": _time_basis(window),
"window_method": method,
"stability_verified": verified,
"samples_total": total,
"samples_valid": total - excluded,
"samples_excluded": excluded,
"axis_samples": {axis: stats[axis]["n"] for axis in AXES},
"FN_mean": stats["FN"]["mean"],
"FX_mean": stats["FX"]["mean"],
"FY_mean": stats["FY"]["mean"],
"FN_std": stats["FN"]["std"],
"FX_std": stats["FX"]["std"],
"FY_std": stats["FY"]["std"],
"unit": UNIT,
"trial_status": trial_status,
"capture_status": capture[0],
"capture_reason": capture[1],
"review_status": (
"confirmed" if verified else window_status or "unavailable"
),
"eligible": not reason,
"excluded_reason": reason,
})
for window in windows:
if (int(window["step_index"]), int(window["hold_start_host_mono_ns"])) in matched:
continue
rows.append(_orphan_window_row(root, review, trial, window, cache))
# UV coordinates belong to the site record, not to the attempt.
sites = {site["site_id"]: site for site in review["sites"]}
for row in rows:
site = sites.get(row["site_id"])
uv = site.get("uv_xy_px") if site else None
if isinstance(uv, (list, tuple)) and len(uv) == 2:
row["U"], row["V"] = float(uv[0]), float(uv[1])
row["point"] = f"U {row['U']:g}, V {row['V']:g}" if row["U"] is not None else ""
return rows
def _time_basis(window: dict | None) -> str:
if not window:
return ""
bases = window.get("time_bases") or []
return bases[0] if len(bases) == 1 else ""
def _orphan_window_row(
root: Path, review: dict, trial: dict, window: dict, cache: dict
) -> dict:
"""A reviewed window whose reference row no longer matches: still listed."""
capture = _capture_status(root, trial)
return {
"schema_version": SCHEMA_VERSION, "summary_id": "",
"session_id": review["session_id"], "trial_id": trial["trial_id"],
"hold_id": -1, "site_id": str(trial.get("site_id") or ""), "point": "",
"repeat_index": trial.get("repeat_index"), "step_index": int(window["step_index"]),
"branch": str(window.get("branch") or ""), "source_file": f"{trial['directory']}/reference.csv",
"U": None, "V": None,
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
"reference_source": str(window.get("reference_source") or ""),
"target_load_n": window.get("target_load_n"),
"reference_load_n": window.get("reference_n"),
"t_start_host_mono_ns": int(window["t_start_host_mono_ns"]),
"t_end_host_mono_ns": int(window["t_end_host_mono_ns"]),
"time_basis": _time_basis(window), "window_method": HOLD_WINDOW_METHOD,
"stability_verified": False, "samples_total": 0, "samples_valid": 0,
"samples_excluded": 0, "axis_samples": {axis: 0 for axis in AXES},
"FN_mean": None, "FX_mean": None, "FY_mean": None,
"FN_std": None, "FX_std": None, "FY_std": None, "unit": UNIT,
"trial_status": str(trial.get("status") or ""),
"capture_status": capture[0], "capture_reason": capture[1],
"review_status": str(window.get("status") or ""),
"eligible": False, "excluded_reason": REASON_NO_DATA,
}
def fit_axis(
points: list[tuple[float, float]],
) -> dict:
"""Forward OLS ``raw = slope * reference + intercept`` for one axis."""
finite = [(x, y) for x, y in points if math.isfinite(x) and math.isfinite(y)]
loads = sorted({x for x, _ in finite})
if not finite:
return {"status": REASON_NO_DATA}
if len(loads) < 2:
return {"status": REASON_FEWER_LEVELS}
x = np.array([value for value, _ in finite], dtype=float)
y = np.array([value for _, value in finite], dtype=float)
x_mean, y_mean = float(x.mean()), float(y.mean())
sxx = float(((x - x_mean) ** 2).sum())
if sxx <= 0:
return {"status": REASON_FEWER_LEVELS}
slope = float(((x - x_mean) * (y - y_mean)).sum() / sxx)
intercept = y_mean - slope * x_mean
predicted = slope * x + intercept
residuals = y - predicted
sse = float((residuals ** 2).sum())
sst = float(((y - y_mean) ** 2).sum())
r2 = 1.0 - sse / sst if sst > 0 else None
return {
"status": "available" if r2 is not None else REASON_FLAT,
"slope_raw_per_N": slope,
"intercept_raw": intercept,
"R2": r2,
"rmse_raw": float(np.sqrt(sse / len(y))),
"reference_min_N": min(loads),
"reference_max_N": max(loads),
"window_count": len(finite),
}
def _load_means(rows: list[dict], axis: str) -> dict[float, float]:
"""Mean raw value per declared reference load, over the eligible rows."""
groups: dict[float, list[float]] = {}
for row in rows:
load = row["reference_load_n"]
value = row[f"{axis}_mean"]
if load is None or value is None or not row["eligible"]:
continue
groups.setdefault(float(load), []).append(float(value))
return {load: float(np.mean(values)) for load, values in groups.items()}
def _endpoint(rows: list[dict], axis: str) -> float | None:
values = _load_means(rows, axis)
zero = values.get(0.0)
positive = [load for load in values if load > 0]
if zero is None or not positive:
return None
high = max(positive)
return (values[high] - zero) / high
def _initial_step(rows: list[dict], axis: str) -> float | None:
values = _load_means(rows, axis)
zero = values.get(0.0)
positive = sorted(load for load in values if load > 0)
if zero is None or not positive:
return None
return values[positive[0]] - zero
def build_sensitivity(windows: list[dict]) -> list[dict]:
"""Per point, trial, axis and review set: a fit that can be recomputed."""
rows: list[dict] = []
groups: dict[tuple, list[dict]] = {}
for window in windows:
if not window["eligible"]:
continue
if window["branch"] not in {"zero", "baseline", "load"}:
continue # unloading is shown, never fitted
groups.setdefault(
(window["site_id"], window["trial_id"], bool(window["stability_verified"])),
[],
).append(window)
for (site_id, trial_id, verified), group in sorted(groups.items()):
for axis in AXES:
key = f"{axis}_mean"
points = [
(float(row["reference_load_n"]), float(row[key]))
for row in group
if row["reference_load_n"] is not None and row[key] is not None
]
fit = fit_axis(points)
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": "",
"session_id": group[0]["session_id"],
"site_id": site_id,
"trial_id": trial_id,
"repeat_index": group[0]["repeat_index"],
"step_index": "",
"component": axis,
"review_status": "confirmed" if verified else "candidate",
"method": FIT_METHOD,
"slope_raw_per_N": fit.get("slope_raw_per_N"),
"intercept_raw": fit.get("intercept_raw"),
"R2": fit.get("R2"),
"rmse_raw": fit.get("rmse_raw"),
"endpoint_raw_per_N": _endpoint(group, axis),
"initial_step_raw": _initial_step(group, axis),
"reference_min_N": fit.get("reference_min_N"),
"reference_max_N": fit.get("reference_max_N"),
"window_count": fit.get("window_count", 0),
"status": fit["status"],
"reason": "" if fit["status"] == "available" else fit["status"],
})
return rows
def aggregate_sensitivity(rows: list[dict]) -> list[dict]:
"""Mean and sample deviation of the per-trial slopes. Not a joint fit."""
groups: dict[tuple[str, str], list[dict]] = {}
for row in rows:
if row["status"] != "available" or row["slope_raw_per_N"] is None:
continue
groups.setdefault((row["site_id"], row["component"]), []).append(row)
result = []
for (site_id, component), group in sorted(groups.items()):
slopes = [float(row["slope_raw_per_N"]) for row in group]
result.append({
"site_id": site_id,
"component": component,
"trials": len(slopes),
"slope_mean_raw_per_N": float(np.mean(slopes)),
"slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None,
"window_count": sum(int(row["window_count"] or 0) for row in group),
})
return result
def build_summary(
root: Path,
*,
review: dict | None = None,
selections: dict | None = None,
review_path: Path | None = None,
) -> dict:
"""Build the summary object. Pure: no file is written and no input changes."""
root = Path(root).resolve()
if review is None:
review = review_session(root, selections=selections)
windows = build_windows(root, review)
sensitivity = build_sensitivity(windows)
summary_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{uuid4().hex[:8]}"
for row in [*windows, *sensitivity]:
row["summary_id"] = summary_id
trials = review["trials"]
capture = []
for trial in trials:
status, reason = _capture_status(root, trial)
capture.append({
"trial_id": trial["trial_id"],
"site_id": trial.get("site_id"),
"trial_status": str(trial.get("status") or ""),
"capture_status": status,
"capture_reason": reason,
"capture_report": trial.get("capture_report"),
})
missing = sorted({
row["excluded_reason"] for row in windows if row["excluded_reason"]
})
review_ref = ""
if review_path is not None:
try:
review_ref = str(Path(review_path).resolve().relative_to(root)).replace("\\", "/")
except ValueError:
review_ref = ""
return {
"schema_version": SCHEMA_VERSION,
"summary_id": summary_id,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"software": _versions(),
"source_directory": str(root),
"session_id": review["session_id"],
"source_format": review.get("source_format") or "autocal-session-1.0",
"sensor_model": review.get("sensor_model"),
"sensor_sn": review.get("sensor_sn"),
"units": {
"raw": UNIT,
"reference": "N",
"note": "raw components are not newtons and are not mixed with them",
},
"methods": {
"window_unverified": HOLD_WINDOW_METHOD,
"window_confirmed": REVIEW_WINDOW_METHOD,
"fit": FIT_METHOD,
"endpoint": "highest_load_minus_zero_over_its_reference",
"initial_step": "lowest_positive_mean_minus_zero_mean",
},
"formal_conclusion": FORMAL_CONCLUSION,
"input_sha256": review.get("input_sha256") or {},
"review_relative_path": review_ref,
"selections_sha256": _selections_hash(review.get("selections") or {}),
"capture": capture,
"completion": review.get("completion") or [],
"issues": review.get("issues") or [],
"missing_reasons": missing,
"quality": {
str(site.get("site_id")): site.get("quality")
for site in review.get("sites") or []
},
"windows": windows,
"sensitivity": sensitivity,
"sensitivity_summary": aggregate_sensitivity(sensitivity),
}
def legacy_window_rows(path: Path, *, summary_id: str = "") -> list[dict]:
"""Rows of an old ``stable_means.csv``, marked as having no window evidence.
The historical table has no host times, no standard deviation and no hold
identity, and its per-axis outlier rule is not the new table's. Its numbers
are kept, labelled, and excluded from every fit rather than reinterpreted.
"""
path = Path(path)
if not path.is_file():
return []
rows = []
with path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
rows.append({
"schema_version": SCHEMA_VERSION,
"summary_id": summary_id,
"session_id": "",
"trial_id": "",
"hold_id": -1,
"site_id": str(row.get("site_id") or ""),
"point": "",
"repeat_index": _number(row.get("repeat_index")),
"step_index": _number(row.get("step_index")),
"branch": str(row.get("branch") or ""),
"source_file": str(path.name),
"U": _number(row.get("U")),
"V": _number(row.get("V")),
"mapping_sha256": None,
"reference_source": str(row.get("负载类型") or ""),
"target_load_n": _number(row.get("target_load_n")),
"reference_load_n": _number(row.get("target_load_n")),
"t_start_host_mono_ns": None,
"t_end_host_mono_ns": None,
"time_basis": "",
"window_method": LEGACY_WINDOW_METHOD,
"stability_verified": False,
"samples_total": _number(row.get("samples")) or 0,
"samples_valid": _number(row.get("samples")) or 0,
"samples_excluded": 0,
"axis_samples": {
axis: int(_number(row.get(f"{axis}_mean")) is not None) for axis in AXES
},
"FN_mean": _number(row.get("FN_mean")),
"FX_mean": _number(row.get("FX_mean")),
"FY_mean": _number(row.get("FY_mean")),
"FN_std": None, "FX_std": None, "FY_std": None,
"unit": str(row.get("unit") or UNIT),
"trial_status": "legacy",
"capture_status": "unknown",
"capture_reason": "",
"review_status": "legacy",
"eligible": False,
"excluded_reason": REASON_LEGACY,
})
return rows
@@ -0,0 +1,390 @@
"""Publish one summary: four files, one atomic pointer, no partial result.
The export writes the whole set into a unique temporary directory, verifies what
it wrote, renames that directory into place, and only then replaces
``summary-latest.json`` through an atomic temp-file replace. A failure anywhere
before the rename leaves the previous pointer untouched; the acquisition state is
never rolled back because an export failed.
Nothing is published when the recorded inputs changed since the summary was
built: the numbers would no longer be reproducible from the files on disk.
"""
from __future__ import annotations
import csv
import hashlib
import html
import json
import threading
from pathlib import Path
from uuid import uuid4
from .calibration_session import write_json
from .session_review import review_session
from .summary import SCHEMA_VERSION, build_summary
POINTER_NAME = "summary-latest.json"
POINTER_FORMAT = "autocal-summary-pointer-1.0"
EXPORT_FORMAT = "autocal-summary-export-1.0"
BASELINE_HEADER = (
"schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id",
"point", "repeat_index", "step_index", "branch", "source_file",
"U", "V", "mapping_sha256",
"reference_source", "target_load_n", "reference_load_n",
"t_start_host_mono_ns", "t_end_host_mono_ns", "time_basis",
"window_method", "stability_verified",
"samples_total", "samples_valid", "samples_excluded",
"samples_FN", "samples_FX", "samples_FY",
"FN_mean", "FX_mean", "FY_mean", "FN_std", "FX_std", "FY_std", "unit",
"trial_status", "capture_status", "review_status", "eligible", "excluded_reason",
)
SENSITIVITY_HEADER = (
"schema_version", "summary_id", "session_id", "site_id", "trial_id",
"repeat_index", "component", "review_status", "method",
"slope_raw_per_N", "intercept_raw", "R2", "rmse_raw",
"endpoint_raw_per_N", "initial_step_raw",
"reference_min_N", "reference_max_N", "window_count", "status", "reason",
)
#: One publish per session root at a time.
_LOCKS: dict[str, threading.Lock] = {}
_LOCKS_GUARD = threading.Lock()
class SummaryExportError(RuntimeError):
"""The summary was not published; the previous pointer still stands."""
def _lock_for(root: Path) -> threading.Lock:
key = str(Path(root).resolve())
with _LOCKS_GUARD:
return _LOCKS.setdefault(key, threading.Lock())
def _cell(value) -> str:
"""Full-precision cell. A missing value is empty, never zero."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, float):
return repr(value)
return str(value)
def _row_to_cells(row: dict, header: tuple[str, ...]) -> list[str]:
cells = []
for name in header:
if name.startswith("samples_") and name[8:] in {"FN", "FX", "FY"}:
cells.append(_cell((row.get("axis_samples") or {}).get(name[8:])))
else:
cells.append(_cell(row.get(name)))
return cells
def _write_csv(path: Path, header: tuple[str, ...], rows: list[dict]) -> None:
with path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.writer(handle)
writer.writerow(header)
for row in rows:
writer.writerow(_row_to_cells(row, header))
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _quality_section(summary: dict) -> str:
rows = []
for site_id, quality in sorted((summary.get("quality") or {}).items()):
if not quality:
rows.append(
"<tr><td>%s</td><td colspan=\"4\">%s</td></tr>"
% (html.escape(site_id), html.escape("not available"))
)
continue
validation = quality.get("validation") or {}
rows.append(
"<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>" % (
html.escape(site_id),
html.escape(str(len(quality.get("repeatability") or []))),
html.escape(str(len(quality.get("hysteresis") or []))),
html.escape(str(validation.get("evaluated_windows", 0))),
html.escape(str(validation.get("rmse_n") if validation.get("rmse_n") is not None else "—")),
)
)
return (
"<h2>Repeatability, hysteresis and held-out validation (existing review)</h2>"
"<table><thead><tr><th>Point</th><th>Repeat groups</th><th>Hysteresis rows</th>"
"<th>Held-out windows</th><th>Held-out RMSE (N)</th></tr></thead><tbody>%s</tbody></table>"
% "".join(rows)
)
def _sensitivity_table(summary: dict) -> str:
rows = []
for row in summary.get("sensitivity") or []:
cells = [
html.escape(str(row.get("site_id") or "")),
html.escape(str(row.get("component") or "")),
html.escape(str(row.get("review_status") or "")),
html.escape(_cell(row.get("slope_raw_per_N"))),
html.escape(_cell(row.get("intercept_raw"))),
html.escape(_cell(row.get("R2"))),
html.escape(_cell(row.get("endpoint_raw_per_N"))),
html.escape(_cell(row.get("initial_step_raw"))),
html.escape(str(row.get("window_count") or 0)),
html.escape(str(row.get("status") or "")),
]
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
return (
"<h2>Sensitivity per point, trial and component (raw per N)</h2>"
"<p>Method <code>%s</code>: ordinary least squares of the raw window mean on the "
"actual reference load. Unloading is not fitted. This is not a joint three-axis "
"fit and not a physical accuracy claim.</p>"
"<table><thead><tr><th>Point</th><th>Component</th><th>Review set</th><th>Slope</th>"
"<th>Intercept</th><th>R2</th><th>Endpoint</th><th>Initial step</th>"
"<th>Windows</th><th>Status</th></tr></thead><tbody>%s</tbody></table>"
% (html.escape(str((summary.get("methods") or {}).get("fit", ""))), "".join(rows))
)
def _window_table(summary: dict) -> str:
rows = []
for row in summary.get("windows") or []:
cells = [
html.escape(str(row.get("site_id") or "")),
html.escape(str(row.get("trial_id") or "")),
html.escape(str(row.get("hold_id") or "")),
html.escape(str(row.get("branch") or "")),
html.escape(_cell(row.get("reference_load_n"))),
html.escape(_cell(row.get("FN_mean"))),
html.escape(_cell(row.get("FX_mean"))),
html.escape(_cell(row.get("FY_mean"))),
html.escape(str(row.get("samples_valid") or 0)),
html.escape(str(row.get("window_method") or "")),
html.escape("yes" if row.get("eligible") else "no"),
html.escape(str(row.get("excluded_reason") or "")),
]
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
return (
"<h2>Every hold attempt (raw components)</h2>"
"<p>An attempt is listed even when it is excluded, so nothing disappears quietly. "
"A repeated attempt is excluded with <code>remeasured</code>.</p>"
"<table><thead><tr><th>Point</th><th>Trial</th><th>Hold</th><th>Branch</th>"
"<th>Reference N</th><th>FN</th><th>FX</th><th>FY</th><th>Samples</th>"
"<th>Window</th><th>Eligible</th><th>Excluded reason</th></tr></thead>"
"<tbody>%s</tbody></table>" % "".join(rows)
)
def _evidence_links(summary: dict) -> str:
links = []
for trial in summary.get("capture") or []:
trial_id = str(trial.get("trial_id") or "")
report = trial.get("capture_report") or {}
directory = str(report.get("directory") or "")
if directory:
links.append(
"<li>%s: %s — <code>%s</code></li>"
% (
html.escape(trial_id),
html.escape(str(trial.get("capture_status") or "")),
html.escape(directory),
)
)
else:
links.append(
"<li>%s: %s</li>"
% (html.escape(trial_id), html.escape(str(trial.get("capture_status") or "")))
)
return "<h2>Evidence</h2><ul>%s</ul>" % "".join(links)
def render_html(summary: dict) -> str:
"""One self-contained page; it loads nothing and escapes every value."""
completion = summary.get("completion") or []
done = sum(1 for row in completion if row.get("complete"))
capture = summary.get("capture") or []
incomplete_capture = [row["trial_id"] for row in capture
if row.get("capture_status") in {"incomplete", "missing"}]
pending = sum(1 for row in summary.get("windows") or []
if row.get("review_status") == "candidate" and row.get("eligible"))
missing = ", ".join(summary.get("missing_reasons") or []) or "none"
body = [
"<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\">",
"<title>Autocal summary %s</title>" % html.escape(str(summary.get("summary_id") or "")),
"<style>body{font-family:sans-serif;margin:24px;color:#1b1b1b}"
"table{border-collapse:collapse;margin:8px 0 20px}"
"th,td{border:1px solid #c9c9c9;padding:3px 8px;font-size:13px;text-align:left}"
"th{background:#eee}code{background:#f4f4f4;padding:0 3px}"
".warn{color:#a33}</style></head><body>",
"<h1>Autocal recording summary</h1>",
"<p><b>Model</b> %s · <b>SN</b> %s · <b>Session</b> %s</p>" % (
html.escape(str(summary.get("sensor_model") or "—")),
html.escape(str(summary.get("sensor_sn") or "—")),
html.escape(str(summary.get("session_id") or "—")),
),
"<p><b>Points complete</b> %d / %d · <b>Capture incomplete</b> %d · "
"<b>Windows awaiting review</b> %d · <b>Missing data</b> %s</p>" % (
done, len(completion), len(incomplete_capture), pending, html.escape(missing),
),
"<p class=\"warn\">This page states measurements and their gaps. It is not a "
"pass/fail certificate, and it does not validate physical accuracy.</p>",
_sensitivity_table(summary),
_window_table(summary),
_quality_section(summary),
_evidence_links(summary),
"<p>Schema %s · generated %s</p>" % (
html.escape(str(summary.get("schema_version") or SCHEMA_VERSION)),
html.escape(str(summary.get("generated_at") or "")),
),
"</body></html>",
]
return "\n".join(body)
def _write_export(directory: Path, summary: dict) -> dict[str, str]:
directory.mkdir(parents=True, exist_ok=False)
(directory / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
(directory / "summary.html").write_text(render_html(summary), encoding="utf-8")
_write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or [])
_write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or [])
hashes = {}
for name in ("summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"):
path = directory / name
if not path.is_file():
raise SummaryExportError(f"the export is missing {name}")
hashes[name] = _sha256_file(path)
# Read the JSON back: a summary that cannot be parsed is not a published result.
json.loads((directory / "summary.json").read_text(encoding="utf-8"))
return hashes
def _pointer_for(root: Path, summary: dict, directory: Path, hashes: dict[str, str]) -> dict:
return {
"format_version": POINTER_FORMAT,
"export_format": EXPORT_FORMAT,
"summary_id": summary["summary_id"],
"generated_at": summary.get("generated_at"),
"path": str(directory.relative_to(root)).replace("\\", "/"),
"input_sha256": summary.get("input_sha256") or {},
"selections_sha256": summary.get("selections_sha256"),
"files": hashes,
}
def _read_pointer(root: Path) -> dict:
path = root / POINTER_NAME
if not path.is_file():
return {}
try:
pointer = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return pointer if isinstance(pointer, dict) else {}
def verify_inputs(root: Path, summary: dict) -> None:
"""Refuse to publish numbers the files on disk can no longer reproduce."""
fresh = review_session(root)
if fresh.get("input_sha256") != summary.get("input_sha256"):
raise SummaryExportError(
"recorded inputs changed since this summary was built; nothing was published"
)
if fresh.get("session_id") != summary.get("session_id"):
raise SummaryExportError("the summary belongs to a different session")
def publish_summary(
root: Path,
summary: dict,
*,
verify: bool = True,
cancelled=None,
) -> Path:
"""Write, verify, rename, then move the pointer. Returns the published directory."""
root = Path(root).resolve()
if not (root / "session.json").is_file():
raise SummaryExportError(f"not a session directory: {root}")
if not summary.get("summary_id"):
raise SummaryExportError("the summary has no id")
with _lock_for(root):
if cancelled is not None and cancelled():
raise SummaryExportError("the export was cancelled before it started")
if verify:
verify_inputs(root, summary)
previous = _read_pointer(root)
generated = str(summary.get("generated_at") or "")
if previous.get("generated_at") and generated and generated < str(previous["generated_at"]):
raise SummaryExportError(
"a newer summary is already published; this one would move the pointer back"
)
derived = root / "derived"
derived.mkdir(parents=True, exist_ok=True)
temporary = derived / f".tmp-summary-{uuid4().hex[:8]}"
target = derived / f"summary_{summary['summary_id']}"
if target.exists():
raise SummaryExportError(f"this summary is already published: {target.name}")
try:
hashes = _write_export(temporary, summary)
if cancelled is not None and cancelled():
raise SummaryExportError("the export was cancelled before publishing")
temporary.rename(target)
except Exception as exc:
if temporary.exists():
# Best effort: the failure is already being reported, and a
# leftover temp directory must not replace that reason.
for leftover in sorted(temporary.rglob("*"), reverse=True):
try:
leftover.unlink()
except OSError:
pass
try:
temporary.rmdir()
except OSError:
pass
if isinstance(exc, SummaryExportError):
raise
raise SummaryExportError(f"could not publish the summary: {exc}") from exc
pointer = _pointer_for(root, summary, target, hashes)
write_json(root / POINTER_NAME, pointer)
return target
def publish_reviewed_summary(
root: Path,
review: dict,
*,
review_path: Path | None = None,
) -> Path:
"""Publish a summary of an existing (already saved) review snapshot."""
summary = build_summary(Path(root), review=review, review_path=review_path)
return publish_summary(root, summary)
def load_summary(root: Path) -> tuple[dict | None, dict]:
"""``(summary, pointer)`` for the last published result, or ``(None, {})``."""
root = Path(root).resolve()
pointer = _read_pointer(root)
if not pointer:
return None, {}
if pointer.get("format_version") != POINTER_FORMAT:
raise SummaryExportError("unsupported pointer format")
path = (root / str(pointer.get("path") or "")) / "summary.json"
if not path.is_file():
raise SummaryExportError("the pointer names a summary that is not there")
for name, digest in (pointer.get("files") or {}).items():
candidate = path.parent / name
if not candidate.is_file() or _sha256_file(candidate) != digest:
raise SummaryExportError(f"the published summary was modified: {name}")
return json.loads(path.read_text(encoding="utf-8")), pointer
+64 -19
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from orisys_app_shared.record import ExperimentRecorder, RecordConfig from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from .calibration_session import CalibrationSession, write_json from .calibration_session import CalibrationSession, write_json
from .capture_guard import CaptureGuard, capture_verdict, PENDING
from .sensor.uv_flat import Site from .sensor.uv_flat import Site
@@ -59,6 +60,10 @@ class ManualTrial:
self.completed_steps: list[dict] = [] self.completed_steps: list[dict] = []
self.reason = "" self.reason = ""
self.recorder: ExperimentRecorder | None = None self.recorder: ExperimentRecorder | None = None
self.guard: CaptureGuard | None = None
self.capture_state = "idle"
self.capture_reason = ""
self.capture_report: dict | None = None
self.directory: Path | None = None self.directory: Path | None = None
self.steps: list[tuple[str, float]] = [] self.steps: list[tuple[str, float]] = []
self.step_index = 0 self.step_index = 0
@@ -66,6 +71,7 @@ class ManualTrial:
self.aborted = False self.aborted = False
self.reference_alignment_possible = False self.reference_alignment_possible = False
self._last_confirm_ns: int | None = None self._last_confirm_ns: int | None = None
self._closing_since_ns: int | None = None
self._reference_file = None self._reference_file = None
self._reference_writer = None self._reference_writer = None
self._active_reference: tuple[int, str, float, float, int] | None = None self._active_reference: tuple[int, str, float, float, int] | None = None
@@ -192,8 +198,14 @@ class ManualTrial:
self.step_index = 0 self.step_index = 0
self.aborted = False self.aborted = False
self._last_confirm_ns = None self._last_confirm_ns = None
self._closing_since_ns = None
self.status = "recording" self.status = "recording"
self.worker.add_sink(recorder.on_packet) self.capture_state = "recording"
self.capture_reason = ""
self.capture_report = None
guard = CaptureGuard(expected_session_id=int(packet.session_id), recorder=recorder)
self.guard = guard
self.worker.add_sink(guard)
self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]}) self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]})
return directory return directory
@@ -346,13 +358,20 @@ class ManualTrial:
) )
self.reason = reason self.reason = reason
self.aborted = aborted self.aborted = aborted
if self.guard is not None:
self.worker.remove_sink(self.guard)
else:
self.worker.remove_sink(self.recorder.on_packet) self.worker.remove_sink(self.recorder.on_packet)
self.recorder.stop(wait=False) self.recorder.stop(wait=False)
self._closing_since_ns = time.perf_counter_ns()
self.status = "closing" self.status = "closing"
self.capture_state = "closing"
def poll(self) -> str: def poll(self) -> str:
if self.status == "recording" and self.recorder is not None: if self.status == "recording" and self.recorder is not None:
if not self.worker.is_running or not self.recorder.is_recording: if self.guard is not None and self.guard.failed:
self.finish(aborted=True, reason="the image source restarted")
elif not self.worker.is_running or not self.recorder.is_recording:
self.finish(aborted=True, reason="input or writer stopped") self.finish(aborted=True, reason="input or writer stopped")
elif self.holding and self.remaining_s <= 0: elif self.holding and self.remaining_s <= 0:
deadline = self._last_confirm_ns + round(self.hold_s * 1e9) deadline = self._last_confirm_ns + round(self.hold_s * 1e9)
@@ -360,32 +379,43 @@ class ManualTrial:
if self._reference_error: if self._reference_error:
self.finish(aborted=True, reason=self._reference_error) self.finish(aborted=True, reason=self._reference_error)
if self.status == "closing" and self.recorder is not None: if self.status == "closing" and self.recorder is not None:
report = self.recorder.report complete = not self.aborted and self.next_step is None
if report is not None: elapsed = (
self.status = ( None if self._closing_since_ns is None
"complete" else (time.perf_counter_ns() - self._closing_since_ns) / 1e9
if (
report.ok
and report.stats.frames > 0
and report.stats.video_frames > 0
and report.stats.segments == 1
and report.stats.dropped_data == 0
and report.stats.dropped_video == 0
and not self.aborted
and self.next_step is None
) )
else "incomplete" verdict = capture_verdict(
self.recorder,
complete=complete,
reason=self.reason,
guard=self.guard,
elapsed_s=elapsed,
) )
if verdict.status == PENDING:
return self.status
self.status = verdict.status
self.capture_state = verdict.status
self.capture_reason = verdict.reason
self.capture_report = verdict.report
self.entry.update({ self.entry.update({
"status": self.status, "status": self.status,
"reason": self.reason or ("" if self.status == "complete" else "recording integrity check failed"), "reason": self.reason or ("" if self.status == "complete" else verdict.reason),
"steps": list(self.completed_steps), "steps": list(self.completed_steps),
"capture_status": verdict.status,
"capture_reason": verdict.reason,
"capture_report": verdict.report,
}) })
try: try:
trial_path = self.directory / "session.json" trial_path = self.directory / "session.json"
document = json.loads(trial_path.read_text(encoding="utf-8")) document = json.loads(trial_path.read_text(encoding="utf-8"))
document.update({"trial_status": self.status, "steps": self.completed_steps, document.update({
"trial_reason": self.entry["reason"]}) "trial_status": self.status,
"steps": self.completed_steps,
"trial_reason": self.entry["reason"],
"capture_status": verdict.status,
"capture_reason": verdict.reason,
"capture_report": verdict.report,
})
write_json(trial_path, document) write_json(trial_path, document)
self.session.save() self.session.save()
except (OSError, ValueError): except (OSError, ValueError):
@@ -393,3 +423,18 @@ class ManualTrial:
self.entry["status"] = "incomplete" self.entry["status"] = "incomplete"
raise raise
return self.status return self.status
def capture_summary(self) -> dict:
"""Live recording state for the operator label; no disk read."""
recorder = self.recorder
stats = recorder.stats() if recorder is not None else None
return {
"state": self.capture_state,
"reason": self.capture_reason,
"frames": int(getattr(stats, "frames", 0) or 0),
"video_frames": int(getattr(stats, "video_frames", 0) or 0),
"dropped_data": int(getattr(stats, "dropped_data", 0) or 0),
"dropped_video": int(getattr(stats, "dropped_video", 0) or 0),
"duration_s": float(getattr(stats, "duration_s", 0.0) or 0.0),
"directory": str(self.directory or ""),
}
@@ -29,9 +29,12 @@ from PySide6.QtWidgets import (
from ..sensor.uv_flat import site_at, uv_origin from ..sensor.uv_flat import site_at, uv_origin
from ..sensor.uv_outline import outline_snapshot from ..sensor.uv_outline import outline_snapshot
from ..summary import build_summary
from ..summary_export import publish_summary
from ..trial import ManualTrial, load_path from ..trial import ManualTrial, load_path
from .live_force_view import LiveForceView from .live_force_view import LiveForceView
from .multipoint_panel import MultipointPanel from .multipoint_panel import MultipointPanel
from .review_job import ReviewJob
from .sensor_panel import FlatSurfaceView from .sensor_panel import FlatSurfaceView
#: Product models a session can be recorded under. The stored value is this id. #: Product models a session can be recorded under. The stored value is this id.
@@ -46,6 +49,9 @@ class CalibrationPanel(QWidget):
def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None: def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._summary_job: ReviewJob | None = None
self._summary_directory: Path | None = None
self._summary_ready_path: Path | None = None
self.source_panel = source_panel self.source_panel = source_panel
self.trial = ManualTrial(source_panel.worker) self.trial = ManualTrial(source_panel.worker)
self._flat = None self._flat = None
@@ -305,6 +311,10 @@ class CalibrationPanel(QWidget):
def _confirm(self) -> None: def _confirm(self) -> None:
if self.multipoint.campaign.phase == "load": if self.multipoint.campaign.phase == "load":
if not self.multipoint.campaign.capture_ready:
self.result_label.setText(t("multipoint.capture_wait"))
self._sync()
return
self.multipoint.confirm_loaded() self.multipoint.confirm_loaded()
return return
try: try:
@@ -366,8 +376,46 @@ class CalibrationPanel(QWidget):
) )
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
self.result_label.setText(t("calibration.error").format(detail=exc)) self.result_label.setText(t("calibration.error").format(detail=exc))
else:
self.publish_summary_for(directory)
self._sync() self._sync()
@property
def summary_publishing(self) -> bool:
return self._summary_job is not None
def publish_summary_for(self, directory) -> bool:
"""Build and publish the candidate summary in the background.
An export failure is reported and can be retried; it never changes the
acquisition result, and it never blocks the event loop.
"""
if self._summary_job is not None:
return False
path = Path(directory)
if not path.is_dir():
return False
self._summary_directory = path
self._summary_ready_path = None
def action():
summary = build_summary(path)
return publish_summary(path, summary)
self._summary_job = ReviewJob(action, self._summary_finished)
self._summary_job.start()
return True
def _summary_finished(self, value, error) -> None:
self._summary_job = None
if error is not None:
# The pointer still names the previous summary; the recording stays
# exactly as it was.
self.result_label.setText(t("summary.failed").format(detail=error))
return
self._summary_ready_path = Path(value)
self.result_label.setText(t("summary.published").format(path=value))
def _review(self) -> None: def _review(self) -> None:
session = self.trial.session session = self.trial.session
directory = QFileDialog.getExistingDirectory( directory = QFileDialog.getExistingDirectory(
@@ -413,9 +461,11 @@ class CalibrationPanel(QWidget):
) )
can_confirm = recording and self.trial.next_step is not None and not self.trial.holding can_confirm = recording and self.trial.next_step is not None and not self.trial.holding
multipoint_load = self.multipoint.campaign.phase == "load" multipoint_load = self.multipoint.campaign.phase == "load"
self.confirm_button.setEnabled(can_confirm or multipoint_load) multipoint_ready = self.multipoint.campaign.capture_ready
multipoint_confirm = multipoint_load and multipoint_ready
self.confirm_button.setEnabled(can_confirm or multipoint_confirm)
self.reference.setEnabled( self.reference.setEnabled(
can_confirm or (multipoint_load and self.multipoint.source.currentData() != "wrench") can_confirm or (multipoint_confirm and self.multipoint.source.currentData() != "wrench")
) )
self.finish_button.setEnabled(recording and self.trial.next_step is None) self.finish_button.setEnabled(recording and self.trial.next_step is None)
self.abort_button.setEnabled(recording) self.abort_button.setEnabled(recording)
@@ -433,7 +483,11 @@ class CalibrationPanel(QWidget):
else: else:
target_text = t("calibration.no_step") target_text = t("calibration.no_step")
if self.multipoint.campaign.phase == "load": if self.multipoint.campaign.phase == "load":
target_text = t("multipoint.right_confirm") target_text = (
t("multipoint.right_confirm")
if self.multipoint.campaign.capture_ready
else t("multipoint.capture_wait")
)
self.target_label.setText(target_text) self.target_label.setText(target_text)
self.progress_label.setText( self.progress_label.setText(
t("calibration.progress").format( t("calibration.progress").format(
@@ -145,8 +145,11 @@ class MultipointPanel(QWidget):
self.progress = _WrappingLabel(page) self.progress = _WrappingLabel(page)
self.save_status = _WrappingLabel(page) self.save_status = _WrappingLabel(page)
self.capture_status = _WrappingLabel(page)
self.capture_status.setObjectName("multipointCapture")
layout.addWidget(self.progress) layout.addWidget(self.progress)
layout.addWidget(self.save_status) layout.addWidget(self.save_status)
layout.addWidget(self.capture_status)
self.pose_status = _WrappingLabel(page) self.pose_status = _WrappingLabel(page)
layout.addWidget(self.pose_status) layout.addWidget(self.pose_status)
@@ -222,6 +225,7 @@ class MultipointPanel(QWidget):
self._timer.timeout.connect(self._poll) self._timer.timeout.connect(self._poll)
self._timer.start() self._timer.start()
self._listed: tuple = () self._listed: tuple = ()
self._summary_published_for = None
self.retranslate() self.retranslate()
self.site_id.setText(self._next_site_id()) self.site_id.setText(self._next_site_id())
self.frame.setText("sensor") self.frame.setText("sensor")
@@ -239,6 +243,9 @@ class MultipointPanel(QWidget):
self._timer.stop() self._timer.stop()
if self.campaign.is_active: if self.campaign.is_active:
self.campaign.stop("application closed") self.campaign.stop("application closed")
# Bounded only at close time: the window cannot wait forever, and a
# writer that misses the deadline leaves an explicitly incomplete repeat.
self.campaign.flush_close()
def retranslate(self) -> None: def retranslate(self) -> None:
self.hint.setText(t("multipoint.hint")) self.hint.setText(t("multipoint.hint"))
@@ -471,6 +478,7 @@ class MultipointPanel(QWidget):
def go(): def go():
if self.host.trial.status in {"recording", "closing"}: if self.host.trial.status in {"recording", "closing"}:
raise RuntimeError(t("multipoint.finger_busy")) raise RuntimeError(t("multipoint.finger_busy"))
self._summary_published_for = None
if not simulate: if not simulate:
worker = self.host.source_panel.worker worker = self.host.source_panel.worker
packet = worker.latest() packet = worker.latest()
@@ -487,10 +495,17 @@ class MultipointPanel(QWidget):
simulate=simulate, simulate=simulate,
) )
if not simulate: if not simulate:
try:
self.campaign.attach_capture( self.campaign.attach_capture(
self.host.source_panel.worker, self.host.source_panel.worker,
fps=float(self.host.source_panel.mode.fps), fps=float(self.host.source_panel.mode.fps),
) )
except (OSError, RuntimeError, ValueError) as exc:
# A repeat that cannot record its frames must not be offered
# for confirmation: stop it rather than collect unattached
# holds that would look complete.
self.campaign.stop(str(exc))
raise
self.message.setText(str(directory)) self.message.setText(str(directory))
self._run(go) self._run(go)
@@ -537,6 +552,7 @@ class MultipointPanel(QWidget):
def _reacquire(self) -> None: def _reacquire(self) -> None:
if self._selected: if self._selected:
self._summary_published_for = None
self._run(lambda: self.campaign.reacquire(self._selected)) self._run(lambda: self.campaign.reacquire(self._selected))
def _liftoff(self) -> None: def _liftoff(self) -> None:
@@ -553,6 +569,12 @@ class MultipointPanel(QWidget):
self._refresh() self._refresh()
def _poll(self) -> None: def _poll(self) -> None:
try:
self.campaign.poll()
except (OSError, RuntimeError, ValueError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active:
self.campaign.stop(str(exc))
if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0: if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0:
try: try:
self.campaign.complete_hold() self.campaign.complete_hold()
@@ -560,8 +582,21 @@ class MultipointPanel(QWidget):
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
if self.campaign.is_active: if self.campaign.is_active:
self.campaign.stop(str(exc)) self.campaign.stop(str(exc))
self._publish_summary_when_finished()
self._refresh() self._refresh()
def _publish_summary_when_finished(self) -> None:
"""The candidate export starts when the campaign has closed its evidence."""
directory = self.campaign.directory
if directory is None or self.campaign.phase not in {"done", "aborted"}:
return
if self.campaign.capture_state == "closing":
return
if directory == self._summary_published_for:
return
if self.host.publish_summary_for(directory):
self._summary_published_for = directory
def _run(self, action) -> None: def _run(self, action) -> None:
try: try:
action() action()
@@ -604,6 +639,7 @@ class MultipointPanel(QWidget):
for column, value in enumerate(values): for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value)) self.table.setItem(row, column, QTableWidgetItem(value))
active = self.campaign.is_active active = self.campaign.is_active
busy = self.campaign.busy
wrench = self.source.currentData() == "wrench" wrench = self.source.currentData() == "wrench"
self.wrench_box.setVisible(wrench) self.wrench_box.setVisible(wrench)
for widget in ( for widget in (
@@ -611,14 +647,14 @@ class MultipointPanel(QWidget):
self.down_button, self.simulate_button, self.start_button, self.down_button, self.simulate_button, self.start_button,
self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source, self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source,
): ):
widget.setEnabled(not active) widget.setEnabled(not busy)
for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation): for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation):
widget.setEnabled(self.campaign.phase == "load" and wrench) widget.setEnabled(self.campaign.phase == "load" and wrench)
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff") self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step()) self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
self.stop_button.setEnabled(active) self.stop_button.setEnabled(active)
self.reacquire_button.setEnabled( self.reacquire_button.setEnabled(
not active and bool(self._selected) and self.campaign.directory is not None not busy and bool(self._selected) and self.campaign.directory is not None
) )
state = self.campaign.progress() state = self.campaign.progress()
next_id = state["site_id"] if self.campaign.phase == "liftoff" else "" next_id = state["site_id"] if self.campaign.phase == "liftoff" else ""
@@ -669,8 +705,42 @@ class MultipointPanel(QWidget):
save = t("multipoint.save_done") save = t("multipoint.save_done")
elif self.campaign.phase == "aborted": elif self.campaign.phase == "aborted":
save = t("multipoint.save_kept") save = t("multipoint.save_kept")
elif active: elif active or self.campaign.closing:
save = t("multipoint.save_writing") save = t("multipoint.save_writing")
else: else:
save = t("multipoint.save_idle") save = t("multipoint.save_idle")
self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—")) self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—"))
self.capture_status.setText(self._capture_text(state))
def _capture_text(self, state: dict) -> str:
"""Where the recording is, and why it is not complete, in one line."""
capture = state.get("capture") or {}
status = capture.get("state") or "idle"
key = {
"idle": "multipoint.capture_idle",
"recording": "multipoint.capture_recording",
"closing": "multipoint.capture_closing",
"complete": "multipoint.capture_complete",
"incomplete": "multipoint.capture_incomplete",
"not_recorded": "multipoint.capture_not_recorded",
}.get(status, "multipoint.capture_idle")
reason = capture.get("reason") or ""
reason_key = {
"source_restarted": "multipoint.capture_reason_restarted",
"zero_frames": "multipoint.capture_reason_zero_frames",
"write_error": "multipoint.capture_reason_write_error",
"video_missing": "multipoint.capture_reason_video_missing",
"video_undecodable": "multipoint.capture_reason_video_undecodable",
"dropped_data": "multipoint.capture_reason_dropped",
"dropped_video": "multipoint.capture_reason_dropped",
"segment_count": "multipoint.capture_reason_segments",
"closing_timeout": "multipoint.capture_reason_timeout",
"source_stopped": "multipoint.capture_reason_source_stopped",
"not_recorded": "multipoint.capture_reason_not_recorded",
}.get(reason)
return t("multipoint.capture").format(
status=t(key),
frames=int(capture.get("frames") or 0),
dropped=int(capture.get("dropped_data") or 0) + int(capture.get("dropped_video") or 0),
reason=t(reason_key) if reason_key else (reason or "—"),
)
@@ -134,6 +134,7 @@ class RecordOverviewDialog(QDialog):
self._dirty = False self._dirty = False
self._selected_site_id = None self._selected_site_id = None
self.plan = None self.plan = None
self._review_path = None
self.rows = [] self.rows = []
self.setWindowTitle(t("overview.title")) self.setWindowTitle(t("overview.title"))
self.resize(1240, 850) self.resize(1240, 850)
@@ -161,8 +162,10 @@ class RecordOverviewDialog(QDialog):
self.tools.setText(t("overview.tools")) self.tools.setText(t("overview.tools"))
self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self.tools) menu = QMenu(self.tools)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), ("load", self._load)): for name, slot in (("video", self._video), ("summary", self._publish_summary),
action = menu.addAction(t("overview." + name)) ("import_plan", self._import_plan),
("clear_plan", self._clear_plan), ("load", self._load)):
action = menu.addAction(t("overview." + name) if name != "summary" else t("summary.retry"))
action.setObjectName("overview_" + name) action.setObjectName("overview_" + name)
action.triggered.connect(slot) action.triggered.connect(slot)
self.tools.setMenu(menu) self.tools.setMenu(menu)
@@ -546,9 +549,68 @@ class RecordOverviewDialog(QDialog):
self._dirty = self._dirty or before != self.review["selections"] self._dirty = self._dirty or before != self.review["selections"]
self._refresh() self._refresh()
def _video(self):
"""Open the pictures of the selected point's current window."""
from .video_review_dialog import VideoReviewDialog
index = self.table.currentRow()
site_id = self.rows[index]["site_id"] if index >= 0 else None
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
if site is None or not site.get("windows"):
self.notice.setText(t("overview.guide_missing"))
return None
trial_filter = self.trials.currentData()
window = next(
(w for w in site["windows"] if trial_filter is None or w["trial_id"] == trial_filter),
None,
)
if window is None:
self.notice.setText(t("overview.guide_incomplete"))
return None
trial = next(
(item for item in self.review.get("trials") or []
if item["trial_id"] == window["trial_id"]), None,
)
if trial is None or trial.get("status") != "complete":
# An unclosed trial still has the writer's file open.
self.notice.setText(t("overview.guide_incomplete"))
return None
dialog = VideoReviewDialog(
self.directory, self.review,
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
)
# Non-blocking modal: the operator keeps one window, but the caller can
# still be tested without entering a nested event loop.
dialog.setModal(True)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.show()
return dialog
def _save(self): def _save(self):
review, plan = self.review, self.plan review, plan = self.review, self.plan
self._start_job("save", lambda: save_overview(review, plan)) directory = self.directory
def action():
path = save_overview(review, plan)
from ..summary_export import publish_reviewed_summary
summary_path = publish_reviewed_summary(directory, review, review_path=path)
return path, summary_path
self._start_job("save", action)
def _publish_summary(self):
"""Re-publish the summary for the loaded review (the retry path)."""
review = self.review
directory = self.directory
review_path = self._review_path
def action():
from ..summary_export import publish_reviewed_summary
return publish_reviewed_summary(directory, review, review_path=review_path)
self._start_job("summary", action)
@Slot(object, object) @Slot(object, object)
def _job_finished(self, value, error): def _job_finished(self, value, error):
@@ -560,11 +622,17 @@ class RecordOverviewDialog(QDialog):
elif self._job_kind == "save": elif self._job_kind == "save":
self._dirty = False self._dirty = False
self._show_response() self._show_response()
self.message.setText(t("overview.saved")) review_path, summary_path = value
self._review_path = review_path
self.message.setText(t("overview.saved_summary").format(
path=review_path, summary=summary_path))
self.message.show()
self.message.setToolTip(f"{review_path}\n{summary_path}")
elif self._job_kind == "summary":
self.message.setText(t("summary.done").format(path=value))
self.message.show() self.message.show()
self.message.setToolTip(str(value))
else: else:
self.review, self.plan = value self.review, self.plan, self._review_path = value
self._dirty = False self._dirty = False
self._refresh() self._refresh()
@@ -582,4 +650,10 @@ class RecordOverviewDialog(QDialog):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
if path: if path:
directory = self.directory directory = self.directory
self._start_job("load", lambda: load_overview(directory, Path(path))) saved = Path(path)
def action():
review, plan = load_overview(directory, saved)
return review, plan, saved
self._start_job("load", action)
@@ -22,6 +22,16 @@ class ReviewJob(QRunnable):
value = self.action() value = self.action()
except Exception as exc: except Exception as exc:
# Worker failures must return to Qt so controls are always restored. # Worker failures must return to Qt so controls are always restored.
self.result.ready.emit(None, str(exc)) self._emit(None, str(exc))
else: else:
self.result.ready.emit(value, None) self._emit(value, None)
def _emit(self, value, error) -> None:
"""Deliver the result, unless the window it belongs to has gone away."""
try:
self.result.ready.emit(value, error)
except RuntimeError:
# Closing the window deletes the receiver while a read is still in
# flight; there is nobody left to tell, and crashing the pool thread
# would be the only alternative.
return
@@ -92,12 +92,16 @@ class SessionReviewDialog(QDialog):
spin.setSuffix(" s") spin.setSuffix(" s")
self.verified = QCheckBox(t("review.confirm_stable"), self) self.verified = QCheckBox(t("review.confirm_stable"), self)
self.apply_button = QPushButton(t("review.apply_window"), self) self.apply_button = QPushButton(t("review.apply_window"), self)
self.video_button = QPushButton(t("video.open"), self)
self.video_button.setObjectName("reviewVideo")
self.video_button.clicked.connect(self._video)
editor.addWidget(QLabel(t("review.start_offset"), self)) editor.addWidget(QLabel(t("review.start_offset"), self))
editor.addWidget(self.start_s) editor.addWidget(self.start_s)
editor.addWidget(QLabel(t("review.end_offset"), self)) editor.addWidget(QLabel(t("review.end_offset"), self))
editor.addWidget(self.end_s) editor.addWidget(self.end_s)
editor.addWidget(self.verified) editor.addWidget(self.verified)
editor.addWidget(self.apply_button) editor.addWidget(self.apply_button)
editor.addWidget(self.video_button)
hold_layout.addLayout(editor) hold_layout.addLayout(editor)
self.trace_chart = QChart() self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self) self.trace_view = QChartView(self.trace_chart, self)
@@ -185,6 +189,7 @@ class SessionReviewDialog(QDialog):
window = self._current_window() window = self._current_window()
clear_chart(self.trace_chart) clear_chart(self.trace_chart)
self.apply_button.setEnabled(window is not None) self.apply_button.setEnabled(window is not None)
self.video_button.setEnabled(window is not None)
if window is None: if window is None:
return return
origin = window["hold_start_host_mono_ns"] origin = window["hold_start_host_mono_ns"]
@@ -217,6 +222,30 @@ class SessionReviewDialog(QDialog):
self.trace_chart.legend().hide() self.trace_chart.legend().hide()
self.trace_chart.setTitle(t("review.trace_title")) self.trace_chart.setTitle(t("review.trace_title"))
def _video(self):
"""Open the recorded pictures of the selected hold, read-only."""
window = self._current_window()
if window is None:
return None
trial = next(
(item for item in self.review.get("trials") or []
if item["trial_id"] == window["trial_id"]), None,
)
if trial is None or trial.get("status") != "complete":
# Only a closed trial has a finished container to decode.
self.message.setText(t("overview.guide_incomplete"))
return None
from .video_review_dialog import VideoReviewDialog
dialog = VideoReviewDialog(
self.directory, self.review,
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
)
dialog.setModal(True)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.show()
return dialog
def _apply_window(self): def _apply_window(self):
window = self._current_window() window = self._current_window()
if window is None: if window is None:
@@ -279,7 +308,16 @@ class SessionReviewDialog(QDialog):
def _save(self) -> None: def _save(self) -> None:
review = self.review review = self.review
self._start_job("save", lambda: save_review(review)) directory = self.directory
def action():
path = save_review(review)
from ..summary_export import publish_reviewed_summary
summary_path = publish_reviewed_summary(directory, review, review_path=path)
return path, summary_path
self._start_job("save", action)
def _start_job(self, kind, action): def _start_job(self, kind, action):
if self._job is not None: if self._job is not None:
@@ -302,4 +340,6 @@ class SessionReviewDialog(QDialog):
self.review = value self.review = value
self._show_site() self._show_site()
else: else:
self.message.setText(t("review.saved").format(path=value)) review_path, summary_path = value
self.message.setText(t("review.saved_summary").format(
path=review_path, summary=summary_path))
@@ -0,0 +1,359 @@
"""Play back one reviewed hold window from the recording that already exists.
The dialog decodes frames off the Qt thread and drops results that belong to a
request the operator has already moved past, so switching windows quickly cannot
show the previous hold's picture. Closing it releases the decoder.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import QPointF, Qt, QTimer, Slot
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QVBoxLayout,
)
from ..video_review import Location, open_window_video
from .chart_helpers import clear_chart
from .review_job import ReviewJob
#: Playback advances one recorded frame per tick; the recorded timing is the
#: frame index, not the container's nominal rate.
PLAYBACK_INTERVAL_MS = 100
class VideoReviewDialog(QDialog):
"""The pictures of one hold, beside the curve the operator reviewed."""
def __init__(
self,
directory: Path,
review: dict,
*,
trial_id: str,
step_index: int,
parent=None,
) -> None:
super().__init__(parent)
self.setObjectName("videoReview")
self.resize(980, 720)
self.setWindowTitle(t("video.title"))
self.directory = Path(directory)
self.review = review
self.trial_id = trial_id
self.step_index = int(step_index)
self.window = self._window()
self.video = None
self.location: Location | None = None
self.index = 0
self._generation = 0
self._playing = False
self._job = None
self._reason = ""
self._closed = False
layout = QVBoxLayout(self)
self.header = QLabel(self)
self.header.setWordWrap(True)
layout.addWidget(self.header)
self.image = QLabel(self)
self.image.setMinimumSize(480, 320)
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image.setStyleSheet("#videoReview QLabel#videoFrame {background:#111;color:#ddd;}")
self.image.setObjectName("videoFrame")
layout.addWidget(self.image, 3)
self.curve = QChart()
self.curve_view = QChartView(self.curve, self)
self.curve_view.setMinimumHeight(180)
layout.addWidget(self.curve_view, 2)
controls = QHBoxLayout()
self.prev_button = QPushButton(t("video.previous"), self)
self.play_button = QPushButton(t("video.play"), self)
self.next_button = QPushButton(t("video.next"), self)
self.slider = QSlider(Qt.Orientation.Horizontal, self)
self.slider.setMinimum(0)
controls.addWidget(self.prev_button)
controls.addWidget(self.play_button)
controls.addWidget(self.next_button)
controls.addWidget(self.slider, 1)
layout.addLayout(controls)
self.position = QLabel(self)
self.position.setWordWrap(True)
layout.addWidget(self.position)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
self.prev_button.clicked.connect(lambda: self._step(-1))
self.next_button.clicked.connect(lambda: self._step(1))
self.play_button.clicked.connect(self._toggle_play)
self.slider.valueChanged.connect(self._slider_moved)
self.timer = QTimer(self)
self.timer.setInterval(PLAYBACK_INTERVAL_MS)
self.timer.timeout.connect(lambda: self._step(1, from_playback=True))
self._describe_window()
self._draw_curve()
self._open()
# ------------------------------------------------------------- set-up
def _window(self) -> dict:
for site in self.review.get("sites") or []:
for window in site.get("windows") or []:
if window["trial_id"] == self.trial_id and int(window["step_index"]) == self.step_index:
return window
return {}
def _describe_window(self) -> None:
window = self.window
if not window:
self.header.setText(t("video.reason_window_not_found"))
return
self.header.setText(t("video.header").format(
trial=self.trial_id,
step=window.get("step_index"),
branch=t("calibration." + str(window.get("branch") or "load")),
load=f"{float(window.get('reference_n') or 0.0):g}",
status=str(window.get("status") or ""),
samples=window.get("samples", 0),
))
def _open(self) -> None:
if not self.window:
self._disable(t("video.reason_window_not_found"))
return
self._set_busy(True)
review = self.review
trial_id, step_index = self.trial_id, self.step_index
review_job = ReviewJob(
lambda: open_window_video(review, trial_id=trial_id, step_index=step_index),
self._opened,
)
self._job = review_job
review_job.start()
@Slot(object, object)
def _opened(self, value, error) -> None:
self._job = None
if self._closed:
if error is None and value is not None:
value.close()
return
self._set_busy(False)
if error is not None:
self._disable(self._reason_text(error))
return
self.video = value
self.location = value.location
self.slider.setMaximum(max(0, value.frame_count - 1))
self.slider.setValue(0)
self.prev_button.setEnabled(True)
self.next_button.setEnabled(True)
self.play_button.setEnabled(value.frame_count > 0)
self._request(0)
def _reason_text(self, error: str) -> str:
key = {
"no_frame_source": "video.reason_no_frame_source",
"no_video_frame": "video.reason_no_video_frame",
"trial_not_found": "video.reason_trial_not_found",
"window_not_found": "video.reason_window_not_found",
"hold_not_found": "video.reason_hold_not_found",
"hold_not_complete": "video.reason_hold_not_complete",
"video_undecodable": "video.reason_video_undecodable",
}.get(str(error))
return t(key) if key else t("video.reason_missing").format(detail=error)
def _disable(self, message: str) -> None:
self._reason = message
self.play_button.setEnabled(False)
self.prev_button.setEnabled(False)
self.next_button.setEnabled(False)
self.slider.setEnabled(False)
self.image.setText(message)
self.position.setText(message)
self.message.setText(message)
def _set_busy(self, busy: bool) -> None:
for widget in (self.play_button, self.prev_button, self.next_button, self.slider):
widget.setEnabled(not busy)
if busy:
self.message.setText(t("review.checking"))
# ------------------------------------------------------------ playback
def _request(self, index: int) -> None:
video = self.video
if video is None or video.frame_count == 0 or self._closed:
return
index = max(0, min(int(index), video.frame_count - 1))
self.index = index
self._generation += 1
generation = self._generation
self._show_position(index)
job = ReviewJob(
lambda: (generation, index, video.frame(index)),
self._frame_ready,
)
self._job = job
job.start()
@Slot(object, object)
def _frame_ready(self, value, error) -> None:
self._job = None
if self._closed:
# The decoder was released with the dialog; a late picture must not
# reopen it or paint over the closed window.
return
if error is not None:
self.message.setText(t("video.reason_decode"))
return
generation, index, image = value
if generation != self._generation:
return # the operator moved on; this picture belongs to an old request
if image is None:
self.image.setText(t("video.frame_missing"))
self.message.setText(t("video.frame_missing"))
return
self.image.setPixmap(self._pixmap(image))
self.message.clear()
self._draw_cursor(index)
def _pixmap(self, image: np.ndarray) -> QPixmap:
rgb = np.ascontiguousarray(np.asarray(image)[:, :, ::-1])
height, width = rgb.shape[:2]
qimage = QImage(rgb.data, width, height, 3 * width, QImage.Format.Format_RGB888)
return QPixmap.fromImage(qimage.copy()).scaled(
self.image.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def _slider_moved(self, value: int) -> None:
if self.video is None:
return
if value != self.index:
self._request(value)
def _step(self, delta: int, *, from_playback: bool = False) -> None:
if self.video is None or self.video.frame_count == 0:
return
target = self.index + delta
if target >= self.video.frame_count:
self._stop_playback()
return
target = max(0, target)
self.slider.blockSignals(True)
self.slider.setValue(target)
self.slider.blockSignals(False)
self._request(target)
if not from_playback:
self._stop_playback()
def _toggle_play(self) -> None:
if self._playing:
self._stop_playback()
else:
self._playing = True
self.play_button.setText(t("video.pause"))
self.timer.start()
def _stop_playback(self) -> None:
self._playing = False
self.timer.stop()
self.play_button.setText(t("video.play"))
def _show_position(self, index: int) -> None:
location = self.location
window = self.window
if location is None:
return
frame = self.video.frames[index] if self.video is not None else None
self.position.setText(t("video.position").format(
index=index + 1,
total=self.video.frame_count if self.video is not None else 0,
frame=frame.frame_id if frame is not None else "—",
segment=frame.segment if frame is not None else "—",
segment_frame=frame.segment_frame if frame is not None else "—",
basis=str(window.get("time_basis") or t("video.basis_unknown")),
))
# -------------------------------------------------------------- charts
def _draw_curve(self) -> None:
clear_chart(self.curve)
window = self.window
if not window:
return
origin = int(window["hold_start_host_mono_ns"])
trace = QScatterSeries()
trace.setMarkerSize(3)
trace.setName(t("review.valid_samples"))
trace.replace([
QPointF((stamp - origin) / 1e9, value)
for stamp, _frame, value in window.get("trace") or []
])
self.curve.addSeries(trace)
self.curve_line = QLineSeries()
self.curve_line.setName(t("video.cursor"))
self.curve.addSeries(self.curve_line)
reference = QLineSeries()
reference.setName(t("video.reference"))
reference.append(0.0, float(window.get("reference_n") or 0.0))
reference.append(max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001),
float(window.get("reference_n") or 0.0))
self.curve.addSeries(reference)
axis_x = QValueAxis()
axis_x.setTitleText(t("review.hold_time_axis"))
axis_x.setRange(0, max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001))
axis_y = QValueAxis()
axis_y.setTitleText(t("review.raw_axis"))
values = [value for _stamp, _frame, value in window.get("trace") or []]
values.append(float(window.get("reference_n") or 0.0))
low, high = (min(values), max(values)) if values else (0.0, 1.0)
margin = max((high - low) * 0.1, 0.1)
axis_y.setRange(low - margin, high + margin)
self.curve.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom)
self.curve.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft)
for series in self.curve.series():
series.attachAxis(axis_x)
series.attachAxis(axis_y)
self.curve.legend().hide()
self.curve.setTitle(t("video.curve_title"))
self._cursor_axis_range = (0.0, axis_x.max())
def _draw_cursor(self, index: int) -> None:
if self.video is None or not hasattr(self, "curve_line"):
return
window = self.window
frame = self.video.frames[index]
origin = int(window["hold_start_host_mono_ns"])
x = (frame.t_host_mono_ns - origin) / 1e9
axis = self.curve.axes(Qt.Orientation.Vertical)
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
self.curve_line.replace([QPointF(x, low), QPointF(x, high)])
# ------------------------------------------------------------- teardown
def closeEvent(self, event) -> None:
self._closed = True
self._stop_playback()
if self.video is not None:
self.video.close()
self.video = None
super().closeEvent(event)
def done(self, result: int) -> None:
self._closed = True
self._stop_playback()
if self.video is not None:
self.video.close()
self.video = None
super().done(result)
@@ -0,0 +1,263 @@
"""Find a recorded hold's picture in the video that already exists.
The operator picks a reviewed window and expects the frames of *that* hold. The
position is decided by frame identity — acquisition time plus the recorded
segment and position — never by converting the player's second counter, and
never by assuming the container runs at the acquisition rate.
Read-only: no calibration is recomputed, no frames are invented and a missing
segment stays a reason. The decoder is released by :meth:`WindowVideo.close`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from orisys_app_shared.record import RecordingReader
from .frames_index import (
FrameRow,
read_frame_index,
reference_holds,
resolve_capture_directory,
unique_frames,
)
#: Why a window has no picture. Tokens, localised by the dialog.
NO_TRIAL = "trial_not_found"
NO_WINDOW = "window_not_found"
NO_FRAME_SOURCE = "no_frame_source"
NO_VIDEO_FRAME = "no_video_frame"
HOLD_NOT_FOUND = "hold_not_found"
HOLD_NOT_COMPLETE = "hold_not_complete"
DECODE_FAILED = "video_undecodable"
@dataclass(frozen=True)
class Location:
"""One identified picture position, in recording coordinates."""
trial_id: str
step_index: int
hold_id: int | None
frame_id: int
t_rel_s: float
t_host_mono_ns: int
segment: int
segment_frame: int
capture_directory: str
def as_dict(self) -> dict:
return {
"trial_id": self.trial_id,
"step_index": self.step_index,
"hold_id": self.hold_id,
"frame_id": self.frame_id,
"t_rel_s": self.t_rel_s,
"t_host_mono_ns": self.t_host_mono_ns,
"segment": self.segment,
"segment_frame": self.segment_frame,
"capture_directory": self.capture_directory,
}
def _window(review: dict, *, trial_id: str, step_index: int) -> dict | None:
for site in review.get("sites") or []:
for window in site.get("windows") or []:
if window["trial_id"] == trial_id and int(window["step_index"]) == int(step_index):
return window
return None
def _trial(review: dict, trial_id: str) -> dict | None:
for trial in review.get("trials") or []:
if trial["trial_id"] == trial_id:
return trial
return None
def in_window_frames(
review: dict, *, trial_id: str, step_index: int
) -> list[FrameRow] | str:
"""The in-video frame rows inside one hold, in time order. A token on failure."""
root = Path(review["source_directory"])
trial = _trial(review, trial_id)
if trial is None:
return NO_TRIAL
window = _window(review, trial_id=trial_id, step_index=step_index)
if window is None:
return NO_WINDOW
directory = resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
if directory is None:
return NO_FRAME_SOURCE
start = int(window["hold_start_host_mono_ns"])
end = int(window["hold_end_host_mono_ns"])
unique, _duplicated = unique_frames(read_frame_index(directory))
frames = [
frame for frame in unique.values()
if frame.in_video is True
and frame.segment > 0
and frame.segment_frame > 0
and frame.t_rel_s is not None
and frame.t_host_mono_ns is not None
and start <= frame.t_host_mono_ns < end
]
for frame in frames:
path = directory / f"raw_{frame.segment:04d}.mp4"
if not path.is_file() or path.stat().st_size == 0:
return NO_FRAME_SOURCE
return sorted(frames, key=lambda frame: (frame.t_host_mono_ns, frame.segment, frame.segment_frame))
def _hold(root: Path, trial: dict, hold_id: int) -> dict | None:
for row in reference_holds(root / str(trial.get("directory") or "")):
if row["hold_id"] == int(hold_id):
return row
return None
def locate_window(
review: dict, *, trial_id: str, step_index: int, hold_id: int | None = None
) -> Location | dict:
"""The first valid in-video frame of the hold, or a reason it is missing.
`hold_id` names one actual attempt from `reference.csv`; a discarded
attempt is refused instead of quietly showing the completed one.
"""
trial = _trial(review, trial_id)
if trial is None:
return {"reason": NO_TRIAL}
if hold_id is not None:
hold = _hold(Path(review["source_directory"]), trial, hold_id)
if hold is None:
return {"reason": HOLD_NOT_FOUND}
if not hold["complete"]:
return {"reason": HOLD_NOT_COMPLETE}
if hold["step_index"] != int(step_index):
return {"reason": NO_WINDOW}
window = _window(review, trial_id=trial_id, step_index=step_index)
if window is None:
return {"reason": NO_WINDOW}
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
if isinstance(frames, str):
return {"reason": frames}
if not frames:
return {"reason": NO_VIDEO_FRAME}
# Only a frame that also produced a valid sample is evidence of the hold.
valid_ids = {
int(frame_id) for _stamp, frame_id, _value in window.get("trace") or []
}
root = Path(review["source_directory"])
trial = _trial(review, trial_id)
for frame in frames:
if valid_ids and frame.frame_id not in valid_ids:
continue
directory = resolve_capture_directory(
root,
trial_directory=str(trial.get("directory") or ""),
frame_source=str(window.get("frame_source") or ""),
)
return Location(
trial_id=trial_id,
step_index=int(step_index),
hold_id=None if hold_id is None else int(hold_id),
frame_id=frame.frame_id,
t_rel_s=float(frame.t_rel_s),
t_host_mono_ns=int(frame.t_host_mono_ns),
segment=frame.segment,
segment_frame=frame.segment_frame,
capture_directory=str(directory.relative_to(root)) if directory else "",
)
return {"reason": NO_VIDEO_FRAME}
def _decode_direct(directory: Path, *, segment: int, segment_frame: int):
"""Fallback decode by segment position, for an index that lies about time."""
import cv2
path = directory / f"raw_{segment:04d}.mp4"
if not path.is_file():
return None
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
return None
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
ok, image = capture.read()
return image if ok else None
finally:
capture.release()
@dataclass
class WindowVideo:
"""Decode the frames of one hold, stepping while the operator plays or pauses."""
location: Location
reader: RecordingReader
frames: list[FrameRow]
#: Index of the frame currently decoded, or -1 when the decoder was reset.
position: int = -1
_images: dict[int, np.ndarray] = field(default_factory=dict)
@property
def frame_count(self) -> int:
return len(self.frames)
def frame(self, index: int) -> np.ndarray | None:
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
index = max(0, min(int(index), self.frame_count - 1))
if index in self._images:
return self._images[index]
row = self.frames[index]
try:
image, served = self.reader.video_frame_at(row.t_rel_s)
except Exception: # noqa: BLE001 - a broken decode must not crash playback
image, served = None, None
if image is None or served is None or served.frame_id != row.frame_id:
# The time lookup is "at or before": a repeated timestamp can answer
# with a neighbour. Seek the recorded position instead of showing it.
image = _decode_direct(
self.reader.directory, segment=row.segment, segment_frame=row.segment_frame
)
if image is None:
self.position = -1
return None
self.position = index
self._images[index] = image
return image
def release(self) -> None:
self.reader.close()
self._images.clear()
self.position = -1
def close(self) -> None:
self.release()
def __enter__(self) -> "WindowVideo":
return self
def __exit__(self, *exc_info) -> None:
self.release()
def open_window_video(review: dict, *, trial_id: str, step_index: int) -> WindowVideo:
"""Open the decoder for one hold. Raises ``ValueError`` carrying the reason token."""
location = locate_window(review, trial_id=trial_id, step_index=step_index)
if not isinstance(location, Location):
raise ValueError(location["reason"])
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
if isinstance(frames, str):
raise ValueError(frames)
root = Path(review["source_directory"])
directory = (root / location.capture_directory).resolve()
reader = RecordingReader(directory)
return WindowVideo(location=location, reader=reader, frames=frames)
+563
View File
@@ -0,0 +1,563 @@
"""Synthetic recording evidence for the acquisition, summary and review tests.
Two rules govern this module:
* a fixture writes the **shared recorder's real headers**, so a reader is tested
against the files it will meet rather than against a convenient subset;
* a video fixture is a **real decodable clip**. A file of fake MP4 bytes proves
nothing about seeking, decoding or frame identity.
Nothing here touches the app's preference file, its ``outputs/`` directory or a
real camera. Fixtures are built inside the test's ``tmp_path``; the run's
``--basetemp`` points at the task scratch directory.
"""
from __future__ import annotations
import csv
import json
import time
from dataclasses import dataclass
from pathlib import Path
from orisys_app_shared.acquisition.models import SdkPacket
from orisys_app_shared.record import FORCES_HEADER, FRAMES_HEADER
#: One working day in nanoseconds: holds inside a fixture are this far apart.
HOLD_STRIDE_NS = 10_000_000_000
#: A fixed, arbitrary session-relative origin for synthetic host timestamps.
BASE_NS = 1_700_000_000_000_000_000
FRAMES_FIELDS = list(FRAMES_HEADER)
FORCES_FIELDS = list(FORCES_HEADER)
def write_csv(path: Path, fields: list[str], rows: list[dict]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return path
def read_csv(path: Path) -> list[dict]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def frame_row(
frame_id: int,
t_host_mono_ns: int,
*,
segment: int = 1,
segment_frame: int | None = None,
in_video: int = 1,
dropped: int = 0,
time_basis: str = "host_camera",
t_rel_s: float = 0.0,
) -> dict:
return {
"frame_id": frame_id,
"source_frame_id": frame_id,
"t_rel_s": repr(float(t_rel_s)),
"t_host_mono_ns": int(t_host_mono_ns),
"t_host_utc_ns": int(t_host_mono_ns),
"acquired_at": "2026-10-17T00:00:00.000+00:00",
"time_basis": time_basis,
"media_time_ms": "",
"segment": segment if in_video else 0,
"segment_frame": (segment_frame if segment_frame is not None else frame_id)
if in_video
else 0,
"in_video": in_video,
"dropped": dropped,
"capture_dropped": 0,
}
def force_row(
frame_id: int,
*,
fn: float | None,
fx: float | None = 0.0,
fy: float | None = 0.0,
valid: int = 1,
session_id: int = 7,
t_rel_s: float = 0.0,
) -> dict:
def cell(value):
return "" if value is None else repr(float(value))
return {
"session_id": session_id,
"frame_id": frame_id,
"t_rel_s": repr(float(t_rel_s)),
"FN_raw": cell(fn),
"FX_raw": cell(fx),
"FY_raw": cell(fy),
"FN_N": "",
"FX_N": "",
"FY_N": "",
"valid": valid,
"calibration_id": "",
"FN_output_raw": "",
"FX_output_raw": "",
"FY_output_raw": "",
"FN_output_N": "",
"FX_output_N": "",
"FY_output_N": "",
"Fmag_raw": "",
}
def write_video(path: Path, *, frames: int = 6, fps: float = 10.0) -> Path:
"""A real, short, decodable clip whose frame number is visible in the picture.
Frame ``i`` carries a white 8x8 marker at column ``4 + (i % 6) * 8``. A test
can therefore ask the decoder which frame it got instead of trusting a seek.
"""
import cv2
import numpy as np
path.parent.mkdir(parents=True, exist_ok=True)
size = (64, 48) # width, height
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), size)
if not writer.isOpened():
raise RuntimeError("the MP4 writer is unavailable in this environment")
try:
for index in range(frames):
image = np.zeros((size[1], size[0], 3), dtype=np.uint8)
column = 4 + (index % 6) * 8
image[4:12, column : column + 8] = 255
writer.write(image)
finally:
writer.release()
return path
def decoded_frame_number(path: Path, *, segment_frame: int | None = None) -> int:
"""Decode one frame of a :func:`write_video` clip and read its marker column."""
import cv2
capture = cv2.VideoCapture(str(path))
try:
if segment_frame is not None:
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
ok, image = capture.read()
if not ok:
raise RuntimeError(f"could not decode {path.name}")
column = int(image[4:12].sum(axis=(0, 2)).argmax())
return (column - 4) // 8
finally:
capture.release()
def write_capture(
directory: Path,
frames: list[dict],
forces: list[dict],
*,
fields: list[str] | None = None,
video_segments: int = 1,
video_frames: int = 6,
) -> Path:
"""Write ``frames.csv``, ``forces.csv`` and one real MP4 per video segment."""
directory.mkdir(parents=True, exist_ok=True)
write_csv(directory / "frames.csv", fields or FRAMES_FIELDS, frames)
write_csv(directory / "forces.csv", FORCES_FIELDS, forces)
for segment in range(1, video_segments + 1):
write_video(directory / f"raw_{segment:04d}.mp4", frames=video_frames)
write_json_file(
directory / "session.json",
{
"format_version": "1.4",
"session_id": forces[0]["session_id"] if forces else 7,
"files": {"frames": "frames.csv", "forces": "forces.csv", "video": "raw_%04d.mp4"},
},
)
return directory
def write_json_file(path: Path, payload: dict) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
@dataclass(frozen=True)
class Hold:
"""One declared reference window as the campaign writes it."""
step_index: int
branch: str
target_load_n: float
reference_n: float
start_ns: int
end_ns: int
complete: int = 1
@property
def stability_verified(self) -> int:
return 0
def reference_row(hold: Hold, *, site_id: str, repeat_index: int, repeat_count: int) -> dict:
row = {name: "" for name in (
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
"t_end_host_mono_ns", "F_ref_normal_N", "site_id", "x", "y", "z",
"coordinate_frame", "repeat_index", "repeat_count", "hold_complete",
"reference_source", "fx_N", "fy_N", "fz_N", "force_to_tool_rotation",
"nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg",
"pose_pitch_deg", "pose_z_mm", "normal_aligned_to",
)}
row.update({
"step_index": hold.step_index,
"branch": hold.branch,
"target_load_n": hold.target_load_n,
"t_start_host_mono_ns": hold.start_ns,
"t_end_host_mono_ns": hold.end_ns,
"F_ref_normal_N": hold.reference_n,
"site_id": site_id,
"x": 0.0, "y": 0.0, "z": 0.0,
"coordinate_frame": "sensor",
"repeat_index": repeat_index,
"repeat_count": repeat_count,
"hold_complete": hold.complete,
"reference_source": "weight",
})
return row
REFERENCE_FIELDS = list(reference_row(
Hold(0, "baseline", 0.0, 0.0, 0, 1), site_id="", repeat_index=1, repeat_count=1,
).keys())
def holds_for_levels(
levels: list[float], *, first_start_ns: int = BASE_NS, hold_ns: int = 4_000_000_000,
) -> list[Hold]:
"""baseline, one load per level, then unload back to zero."""
steps: list[tuple[str, float]] = [("baseline", 0.0)]
steps += [("load", float(level)) for level in levels]
steps += [("unload", value) for value in [*reversed(levels[:-1]), 0.0]]
holds = []
for index, (branch, target) in enumerate(steps):
start = first_start_ns + index * HOLD_STRIDE_NS
holds.append(Hold(index, branch, target, target, start, start + hold_ns))
return holds
def hold_samples(
hold: Hold,
*,
count: int = 3,
first_frame_id: int,
raw_base: float,
raw_per_n: float,
raw_per_axis: dict[str, float] | None = None,
) -> tuple[list[dict], list[dict]]:
"""Evenly spaced samples inside a hold, raw linear in the declared force."""
frames: list[dict] = []
forces: list[dict] = []
span = (hold.end_ns - hold.start_ns) // (count + 1)
fn = raw_base + raw_per_n * hold.reference_n
for offset in range(count):
frame_id = first_frame_id + offset
stamp = hold.start_ns + span * (offset + 1)
frames.append(frame_row(frame_id, stamp, t_rel_s=(stamp - BASE_NS) / 1e9))
values = {"fn": fn}
if raw_per_axis:
values.update({
"fx": raw_per_axis["FX"] * hold.reference_n,
"fy": raw_per_axis["FY"] * hold.reference_n,
})
forces.append(force_row(frame_id, **values))
return frames, forces
def _write_multipoint_trial(
path: Path,
*,
session_id: str,
site_id: str,
repeat_index: int,
repeat_count: int,
holds: list[Hold],
with_capture: bool,
video: bool,
raw_base: float,
raw_per_n: float,
samples_per_hold: int,
frame_start: int,
) -> list[dict]:
write_csv(
path / "reference.csv",
REFERENCE_FIELDS,
[reference_row(hold, site_id=site_id, repeat_index=repeat_index,
repeat_count=repeat_count) for hold in holds],
)
write_csv(
path / "events.csv",
["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"],
[],
)
write_json_file(path / "session.json", {
"trial_id": f"{site_id}-repeat-{repeat_index:03d}",
"site_id": site_id,
"status": "complete",
"uv_px": {"u": 128, "v": 64},
"images": {"captured": with_capture,
"reason": "" if with_capture else "camera not attached yet"},
})
if not with_capture:
return []
frames: list[dict] = []
forces: list[dict] = []
for hold in holds:
hold_frames, hold_forces = hold_samples(
hold,
count=samples_per_hold,
first_frame_id=frame_start + len(frames),
raw_base=raw_base,
raw_per_n=raw_per_n,
)
frames.extend(hold_frames)
forces.extend(hold_forces)
write_capture(path / "capture", frames, forces, fields=None if video else FRAMES_FIELDS,
video_segments=1)
return []
def multipoint_session(
root: Path,
*,
sites: tuple[str, ...] = ("P1",),
repeats: int = 1,
levels: tuple[float, ...] = (5.0,),
with_capture: bool = True,
video: bool = True,
raw_base: float = 100.0,
raw_per_n: float = 40.0,
samples_per_hold: int = 3,
simulate: bool = False,
trial_status: str = "complete",
extra_holds: list[Hold] | None = None,
) -> Path:
"""A complete multi-point session directory through the review contract."""
session_id = f"finger-SN1_{root.name}_test0001"
trials: list[dict] = []
completion: list[dict] = []
site_records = []
for site_id in sites:
site_records.append({
"site_id": site_id,
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
"position_frame": "sensor",
"uv_px": {"u": 128, "v": 64},
"levels_n": list(levels),
"hold_s": 4.0,
"repeats": repeats,
})
complete = 0
for repeat in range(1, repeats + 1):
site_dir = root / "sites" / site_id / f"repeat-{repeat:03d}"
holds = holds_for_levels(list(levels))
if extra_holds:
holds = [*holds, *extra_holds]
directory = f"sites/{site_id}/repeat-{repeat:03d}"
_write_multipoint_trial(
site_dir,
session_id=session_id,
site_id=site_id,
repeat_index=repeat,
repeat_count=repeats,
holds=holds,
with_capture=with_capture,
video=video,
raw_base=raw_base,
raw_per_n=raw_per_n,
samples_per_hold=samples_per_hold,
frame_start=1,
)
trials.append({
"trial_id": f"{site_id}-repeat-{repeat:03d}",
"site_id": site_id,
"repeat_index": repeat,
"directory": directory,
"status": trial_status,
"reason": "" if trial_status == "complete" else "stopped by the operator",
})
if trial_status == "complete":
complete += 1
completion.append({"site_id": site_id, "repeats_planned": repeats,
"repeats_complete": complete,
"complete": complete >= repeats})
write_json_file(root / "session.json", {
"format_version": "autocal-multipoint-1.0",
"session_id": session_id,
"sensor_model": "finger",
"sensor_sn": "SN1",
"coordinate_frame": "sensor",
"max_force_n": 20.0,
"min_force_n": 0.0,
"simulate": simulate,
"status": "complete" if trial_status == "complete" else "incomplete",
"sites": site_records,
"trials": trials,
"completion": completion,
"started_at": "2026-10-17T00:00:00+00:00",
"sync": {"association": "A frame belongs to a hold when its t_host_mono_ns is in "
"[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."},
"platform_motion": False,
"automatic_force_control": False,
})
return root
def single_session(
root: Path,
*,
sites: tuple[str, ...] = ("site-001",),
trials_per_site: int = 1,
levels: tuple[float, ...] = (5.0,),
with_capture: bool = True,
raw_base: float = 100.0,
raw_per_n: float = 40.0,
samples_per_hold: int = 3,
trial_status: str = "complete",
reference_alignment_possible: bool = True,
) -> Path:
"""A complete single-trial calibration session (``autocal-session-1.0``)."""
session_id = f"finger-SN1_{root.name}_test0001"
uv_definition = {
"unit": "px", "u_positive": "right", "v_positive": "up",
"origin_raster_xy_px": [0, 0], "raster_size_hw": [64, 64],
"mapping_sha256": "test-mapping",
}
site_records = [
{"site_id": site_id, "uv_xy_px": [10 + index, 20 + index],
"position_sensor_mm": [1.0, 2.0, 3.0]}
for index, site_id in enumerate(sites)
]
trials: list[dict] = []
for site_index, site_id in enumerate(sites):
for repeat in range(1, trials_per_site + 1):
trial_id = f"trial-{len(trials) + 1:03d}"
directory = f"trials/{trial_id}"
path = root / directory
holds = holds_for_levels(list(levels))
write_csv(path / "reference.csv", REFERENCE_FIELDS, [
reference_row(hold, site_id=site_id, repeat_index=repeat, repeat_count=1)
for hold in holds
])
frames: list[dict] = []
forces: list[dict] = []
if with_capture:
for hold in holds:
hold_frames, hold_forces = hold_samples(
hold, count=samples_per_hold,
first_frame_id=1 + len(frames),
raw_base=raw_base, raw_per_n=raw_per_n,
)
frames.extend(hold_frames)
forces.extend(hold_forces)
write_csv(path / "frames.csv", FRAMES_FIELDS, frames)
write_csv(path / "forces.csv", FORCES_FIELDS, forces)
write_json_file(path / "session.json", {
"trial_id": trial_id,
"site_id": site_id,
"session_id": 7,
"calibration_session_id": session_id,
"trial_status": trial_status,
"trial_reason": "" if trial_status == "complete" else "application closed",
"reference_alignment_possible": reference_alignment_possible,
"uv_definition": uv_definition,
"steps": [{"step_index": hold.step_index, "branch": hold.branch,
"target_load_n": hold.target_load_n} for hold in holds],
})
trials.append({
"trial_id": trial_id, "site_id": site_id, "repeat_index": repeat,
"directory": directory, "status": trial_status,
"reason": "" if trial_status == "complete" else "application closed",
})
write_json_file(root / "session.json", {
"format_version": "autocal-session-1.0",
"session_id": session_id,
"status": "complete" if trial_status == "complete" else "incomplete",
"sites": site_records,
"trials": trials,
"uv_definition": uv_definition,
"sensor_model": "finger",
"sensor_sn": "SN1",
"started_at": "2026-10-17T00:00:00+00:00",
})
return root
class FakeWorker:
"""A fingerprint worker stand-in whose session can be restarted mid-recording."""
def __init__(self, *, session_id: int = 1, frame_id: int = 1, time_basis: str = "host_camera",
clock=None):
self.session_id = int(session_id)
self.is_running = True
self.sinks: list = []
self.time_basis = time_basis
self._clock = clock
self._frame_id = int(frame_id)
self._started = self._now_ns()
self.packet = self._packet()
def _now_ns(self) -> int:
return time.perf_counter_ns() if self._clock is None else int(self._clock())
def _packet(self) -> SdkPacket:
import numpy as np
now = self._now_ns()
return SdkPacket(
raw_frame=np.zeros((16, 16, 3), dtype=np.uint8),
processed_frame=np.zeros((16, 16, 3), dtype=np.uint8),
timestamp=time.time(),
fps=30,
process_ms=1,
session_id=self.session_id,
frame_id=self._frame_id,
mono_t=(now - self._started) / 1e9,
capture_mono_ns=now,
capture_utc_ns=time.time_ns(),
source_frame_id=self._frame_id,
time_basis=self.time_basis,
normal_force_raw=1.0,
)
def advance(self) -> SdkPacket:
self._frame_id += 1
self.packet = self._packet()
return self.packet
def latest(self):
return self.packet
def add_sink(self, sink) -> None:
if sink not in self.sinks:
self.sinks.append(sink)
def remove_sink(self, sink) -> None:
if sink in self.sinks:
self.sinks.remove(sink)
def emit(self) -> None:
for sink in list(self.sinks):
sink(self.packet)
def restart(self) -> None:
"""A new acquisition session with fresh frame numbers, sinks still attached."""
self.session_id += 1
self._frame_id = 1
self._started = self._now_ns()
self.packet = self._packet()
def stop(self, *, wait: bool = True) -> None:
self.is_running = False
@@ -62,6 +62,9 @@ _SCOPED_ENUMS = {
"TextFlag", "TextFlag",
"AspectRatioMode", "AspectRatioMode",
"WindowType", "WindowType",
# ``Qt.WidgetAttribute.WA_DeleteOnClose`` is the scoped spelling too; the
# class simply was not needed before the video review dialog.
"WidgetAttribute",
), ),
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"), "QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
"QSizePolicy": ("Policy",), "QSizePolicy": ("Policy",),
+170
View File
@@ -0,0 +1,170 @@
"""A force row belongs to a frame by identity, never by its line number."""
from __future__ import annotations
import pytest
from evidence_helpers import (
BASE_NS,
force_row,
frame_row,
write_capture,
)
from orisys_autocal.frames_index import (
DUPLICATE_REASON,
joined_samples,
read_frame_index,
window_samples,
)
def _capture(tmp_path, frames, forces, **kwargs):
return write_capture(tmp_path / "capture", frames, forces, video_segments=0, **kwargs)
def test_samples_are_paired_by_frame_id_not_by_row_order(tmp_path):
frames = [
frame_row(1, BASE_NS + 10),
frame_row(2, BASE_NS + 20),
frame_row(3, BASE_NS + 30),
]
# The force rows are reversed on disk, as an out-of-order write would leave them.
forces = [force_row(3, fn=30.0), force_row(2, fn=20.0), force_row(1, fn=10.0)]
_capture(tmp_path, frames, forces)
samples, report = joined_samples(tmp_path / "capture")
assert [sample.frame_id for sample in samples] == [1, 2, 3]
assert [sample.t_host_mono_ns for sample in samples] == [
BASE_NS + 10, BASE_NS + 20, BASE_NS + 30,
]
assert [sample.value("FN") for sample in samples] == [10.0, 20.0, 30.0]
assert report.unjoined_force_rows == 0
window = window_samples(samples, BASE_NS + 15, BASE_NS + 30)
assert [sample.frame_id for sample in window] == [2]
def test_a_repeated_frame_id_excludes_the_samples_it_would_pair(tmp_path):
"""A camera that restarted reuses frame numbers; neither copy is the measurement."""
frames = [
frame_row(1, BASE_NS + 10),
frame_row(2, BASE_NS + 20),
frame_row(1, BASE_NS + 1_000),
frame_row(2, BASE_NS + 1_010),
]
forces = [
force_row(1, fn=10.0),
force_row(2, fn=20.0),
force_row(1, fn=11.0),
force_row(2, fn=21.0),
]
_capture(tmp_path, frames, forces)
samples, report = joined_samples(tmp_path / "capture")
assert samples == []
assert report.duplicate_frame_ids == 2
assert DUPLICATE_REASON in report.reasons
def test_frames_outside_the_video_are_kept_only_when_asked_for(tmp_path):
frames = [
frame_row(1, BASE_NS + 10, in_video=1),
frame_row(2, BASE_NS + 20, in_video=0),
frame_row(3, BASE_NS + 30, in_video=1),
]
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
_capture(tmp_path, frames, forces)
with_video, report = joined_samples(tmp_path / "capture", require_video=True)
everything, _ = joined_samples(tmp_path / "capture")
assert [sample.frame_id for sample in with_video] == [1, 3]
assert [sample.frame_id for sample in everything] == [1, 2, 3]
assert report.frames_not_in_video == 1
def test_a_dropped_frame_row_has_no_time_to_pair_with(tmp_path):
frames = [
frame_row(1, BASE_NS + 10),
frame_row(2, 0, dropped=1),
frame_row(3, BASE_NS + 30),
]
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
_capture(tmp_path, frames, forces)
samples, report = joined_samples(tmp_path / "capture")
assert [sample.frame_id for sample in samples] == [1, 3]
assert report.unjoined_force_rows == 1
assert report.frames_dropped == 1
def test_a_recording_without_an_in_video_column_still_joins(tmp_path):
"""Older recordings have no membership column; unknown is not "outside"."""
fields = [
name for name in (
"frame_id", "source_frame_id", "t_rel_s", "t_host_mono_ns", "t_host_utc_ns",
"acquired_at", "time_basis", "media_time_ms", "segment", "segment_frame",
"dropped", "capture_dropped",
)
]
frames = [
{"frame_id": 1, "t_host_mono_ns": BASE_NS + 10, "dropped": 0, "t_rel_s": "0.0"},
{"frame_id": 2, "t_host_mono_ns": BASE_NS + 20, "dropped": 0, "t_rel_s": "0.0"},
]
_capture(tmp_path, frames, [force_row(1, fn=1.0), force_row(2, fn=2.0)], fields=fields)
samples, report = joined_samples(tmp_path / "capture", require_video=True)
assert [sample.frame_id for sample in samples] == [1, 2]
assert report.frames_not_in_video == 0
def test_a_missing_axis_is_empty_not_zero(tmp_path):
frames = [frame_row(1, BASE_NS + 10), frame_row(2, BASE_NS + 20)]
forces = [
force_row(1, fn=10.0, fx=0.5, fy=-0.25),
force_row(2, fn=None, fx=None, fy=None, valid=0),
]
_capture(tmp_path, frames, forces)
samples, _ = joined_samples(tmp_path / "capture")
assert samples[0].values == (10.0, 0.5, -0.25)
assert samples[1].value("FN") is None
assert samples[1].valid is False
assert samples[1].usable() is False
assert samples[0].usable() is True
def test_the_frame_index_keeps_repeated_ids_and_unknown_video_membership(tmp_path):
frames = [
frame_row(1, BASE_NS + 10, in_video=1),
frame_row(1, BASE_NS + 20, in_video=0),
]
_capture(tmp_path, frames, [force_row(1, fn=1.0)])
index = read_frame_index(tmp_path / "capture")
assert [row.frame_id for row in index] == [1, 1]
assert [row.in_video for row in index] == [True, False]
def test_a_missing_capture_directory_reads_as_empty(tmp_path):
samples, report = joined_samples(tmp_path / "absent")
assert samples == []
assert report.frames_total == 0 and report.force_rows == 0
@pytest.mark.parametrize("frame_id", [0, 120])
def test_frame_ids_are_used_verbatim(tmp_path, frame_id):
"""Zero and large ids are still identities; nothing is renumbered."""
frames = [frame_row(frame_id, BASE_NS + 10)]
_capture(tmp_path, frames, [force_row(frame_id, fn=2.0)])
samples, _ = joined_samples(tmp_path / "capture")
assert samples[0].frame_id == frame_id
+50 -6
View File
@@ -347,8 +347,13 @@ def test_an_outlier_in_the_stable_half_is_left_out_of_its_own_mean(tmp_path):
assert float(row["FY_mean"]) == pytest.approx(-0.375) assert float(row["FY_mean"]) == pytest.approx(-0.375)
def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path): def test_reused_frame_numbers_are_left_out_of_the_means(tmp_path):
"""A camera that starts again reuses frame numbers. Each hold keeps its own rows.""" """A camera that restarts reuses frame numbers; neither copy is a measurement.
Pairing by row order used to keep both copies, which silently moved a sample
into the wrong hold. A repeated identity now excludes the samples it would
pair and the row says so through ``samples``/``unit`` being empty.
"""
from orisys_autocal.multipoint import stable_component_rows from orisys_autocal.multipoint import stable_component_rows
repeat = tmp_path / "repeat-001" repeat = tmp_path / "repeat-001"
@@ -389,10 +394,49 @@ def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path):
rows = stable_component_rows(repeat, site_id="P1", repeat_index=1) rows = stable_component_rows(repeat, site_id="P1", repeat_index=1)
assert float(rows[0]["FN_mean"]) == pytest.approx(20.0) assert rows[0]["branch"] == "load" and rows[1]["branch"] == "unload"
assert rows[0]["branch"] == "load" assert all(row["samples"] == 0 and row["unit"] == "" for row in rows)
assert float(rows[1]["FN_mean"]) == pytest.approx(5.0) assert all(row["FN_mean"] == row["FX_mean"] == row["FY_mean"] == "" for row in rows)
assert rows[1]["branch"] == "unload"
def test_a_missing_force_row_does_not_shift_the_other_holds(tmp_path):
"""The defect the line-number join caused: one lost row moved every later value."""
from orisys_autocal.multipoint import stable_component_rows
repeat = tmp_path / "repeat-001"
(repeat / "capture").mkdir(parents=True)
first, second = (1_000, 1_100), (5_000, 5_100)
with (repeat / "reference.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
"t_end_host_mono_ns", "hold_complete", "reference_source",
])
writer.writeheader()
writer.writerow({"step_index": 1, "branch": "load", "target_load_n": 5,
"t_start_host_mono_ns": first[0], "t_end_host_mono_ns": first[1],
"hold_complete": 1, "reference_source": "weight"})
writer.writerow({"step_index": 2, "branch": "load", "target_load_n": 5,
"t_start_host_mono_ns": second[0], "t_end_host_mono_ns": second[1],
"hold_complete": 1, "reference_source": "weight"})
with (repeat / "capture" / "frames.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["frame_id", "t_host_mono_ns", "dropped"])
writer.writeheader()
for frame_id, stamp in ((1, 1060), (2, 5070)):
writer.writerow({"frame_id": frame_id, "t_host_mono_ns": stamp, "dropped": 0})
# Frame 1's force row was lost; only the second hold has a sample.
with (repeat / "capture" / "forces.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"frame_id", "valid", "FN_output_N", "FX_output_N", "FY_output_N",
])
writer.writeheader()
writer.writerow({"frame_id": 2, "valid": 1,
"FN_output_N": 42.0, "FX_output_N": 0.0, "FY_output_N": 0.0})
rows = stable_component_rows(repeat, site_id="P1", repeat_index=1)
assert rows[0]["FN_mean"] == "" and rows[0]["samples"] == 0
assert float(rows[1]["FN_mean"]) == pytest.approx(42.0)
assert rows[1]["samples"] == 1
def _hold_for(campaign: Campaign, force: float) -> None: def _hold_for(campaign: Campaign, force: float) -> None:
@@ -0,0 +1,362 @@
"""Multi-point capture: repeat ownership, one source session, one verdict.
These tests use the shared recorder with a fake worker and a real camera-sized
packet, so the video file, frame index and closing report are the production
ones. No controller and no real camera are involved.
"""
from __future__ import annotations
import json
import time
from types import SimpleNamespace
import pytest
from evidence_helpers import FakeWorker
from orisys_autocal.multipoint import Campaign
class _Clock:
"""One clock for both the campaign and the fake worker's frame stamps."""
def __init__(self, now: int) -> None:
self.now = int(now)
def advance(self, seconds: float) -> None:
self.now += round(seconds * 1e9)
@pytest.fixture
def clock(monkeypatch):
from orisys_autocal import multipoint as module
value = _Clock(time.perf_counter_ns())
monkeypatch.setattr(module, "time", SimpleNamespace(
perf_counter_ns=lambda: value.now,
perf_counter=lambda: value.now / 1e9,
time_ns=time.time_ns,
))
return value
def _campaign(*, repeats: int = 2) -> Campaign:
campaign = Campaign()
campaign.add_site("P1", 1, 2, 3, [5], 1, repeats, max_force_n=20)
return campaign
def _start(campaign: Campaign, root, *, simulate: bool = False) -> "object":
return campaign.start(
root, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="fixture", max_force_n=20, simulate=simulate,
)
def _drain(campaign: Campaign, timeout: float = 20.0) -> None:
deadline = time.monotonic() + timeout
while campaign.capture_state == "closing" and time.monotonic() < deadline:
campaign.poll()
time.sleep(0.005)
assert campaign.capture_state != "closing", "the writer never closed"
def _record_step(campaign: Campaign, worker: FakeWorker, target: float, *, clock=None) -> None:
campaign.confirm_reference(target)
# Three frames spread across the hold: the summary averages the latter half
# and needs two usable samples there, which is the review's own rule.
for _ in range(3):
if clock is not None:
clock.advance(0.25)
worker.advance()
worker.emit()
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
def _record_repeat(campaign: Campaign, worker: FakeWorker, *, clock=None) -> None:
for _branch, target in list(campaign._steps):
_record_step(campaign, worker, target, clock=clock)
_drain(campaign)
def _session(root) -> dict:
return json.loads((root / "session.json").read_text(encoding="utf-8"))
def test_each_repeat_owns_its_own_video_and_manifest(tmp_path, clock):
campaign = _campaign(repeats=2)
root = _start(campaign, tmp_path)
worker = FakeWorker(clock=lambda: clock.now)
campaign.attach_capture(worker, fps=10.0)
assert campaign.capture_ready
_record_repeat(campaign, worker, clock=clock)
assert campaign.phase == "load"
assert campaign._trial_entry["trial_id"] == "P1-repeat-002"
_record_repeat(campaign, worker, clock=clock)
assert campaign.phase == "done"
_drain(campaign)
session = _session(root)
assert session["status"] == "complete"
trials = {trial["trial_id"]: trial for trial in session["trials"]}
assert set(trials) == {"P1-repeat-001", "P1-repeat-002"}
for trial in trials.values():
assert trial["status"] == "complete", trial
assert trial["capture_status"] == "complete"
assert trial["images"]["captured"] is True
capture = root / trial["directory"] / "capture"
assert (capture / "raw_0001.mp4").is_file()
manifest = json.loads((root / trial["directory"] / "session.json").read_text(encoding="utf-8"))
assert manifest["status"] == "complete"
assert manifest["images"]["captured"] is True
assert manifest["capture_status"] == "complete"
assert manifest["capture_report"]["stats"]["video_frames"] == manifest["capture_report"]["stats"]["frames"] > 0
# The evidence the real recorder wrote is what the summary reads.
from orisys_autocal.summary import build_summary
summary = build_summary(root)
assert summary["windows"]
assert all(row["capture_status"] == "complete" for row in summary["windows"])
assert all(row["eligible"] for row in summary["windows"])
assert all(row["FN_mean"] is not None for row in summary["windows"])
def test_a_restarted_source_aborts_the_repeat_instead_of_mixing_identities(tmp_path):
campaign = _campaign(repeats=1)
root = _start(campaign, tmp_path)
worker = FakeWorker(session_id=4)
campaign.attach_capture(worker, fps=10.0)
_record_step(campaign, worker, campaign._steps[0][1])
# The camera is restarted under the attached sink: new session, frame 1 again.
worker.restart()
worker.emit()
assert campaign.poll()["phase"] in {"closing", "aborted"}
_drain(campaign)
session = _session(root)
trial = session["trials"][0]
assert trial["status"] == "incomplete"
assert trial["capture_status"] == "incomplete"
assert trial["capture_reason"] == "source_restarted"
assert trial["capture_report"]["guard"]["refused"] >= 1
assert session["status"] == "incomplete"
assert campaign.phase == "aborted"
def test_no_recorded_frames_keep_a_confirmed_repeat_incomplete(tmp_path):
"""The operator confirmed every hold, but the evidence is empty."""
campaign = _campaign(repeats=1)
root = _start(campaign, tmp_path)
worker = FakeWorker()
campaign.attach_capture(worker, fps=10.0)
# No packet is emitted, so the writer holds no frame at all.
for _branch, target in list(campaign._steps):
campaign.confirm_reference(target)
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
_drain(campaign)
session = _session(root)
trial = session["trials"][0]
assert trial["status"] == "incomplete"
assert trial["capture_reason"] == "zero_frames"
assert trial["capture_report"]["stats"]["frames"] == 0
assert session["status"] == "incomplete"
def test_a_stopped_source_keeps_the_repeat_incomplete(tmp_path):
"""Frames that stop describing the end of the repeat are not complete evidence."""
campaign = _campaign(repeats=1)
root = _start(campaign, tmp_path)
worker = FakeWorker()
campaign.attach_capture(worker, fps=10.0)
_record_step(campaign, worker, campaign._steps[0][1])
# The camera goes away; the operator keeps confirming holds by hand.
worker.is_running = False
campaign.poll()
assert campaign.phase in {"closing", "aborted"}
_drain(campaign)
session = _session(root)
trial = session["trials"][0]
assert trial["capture_status"] == "incomplete"
assert trial["capture_reason"] == "source_stopped"
assert session["status"] == "incomplete"
def test_a_simulated_repeat_is_steps_complete_but_not_recorded_evidence(tmp_path):
campaign = _campaign(repeats=1)
root = campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="fixture", max_force_n=20, simulate=True,
)
while campaign.phase in {"load", "hold"}:
if campaign.phase == "load":
campaign.confirm_reference(campaign._steps[campaign._step_index][1])
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
session = _session(root)
trial = session["trials"][0]
assert campaign.phase == "done"
assert trial["status"] == "complete" # the operator finished the steps
assert trial["capture_status"] == "not_recorded" # but nothing was recorded
assert trial["capture_reason"] == "not_recorded"
assert session["status"] == "complete"
from orisys_autocal.summary import build_summary
summary = build_summary(root)
assert all(row["capture_status"] == "not_recorded" for row in summary["windows"])
assert all(row["eligible"] is False for row in summary["windows"])
def test_a_new_session_cannot_start_while_the_last_repeat_closes(tmp_path):
campaign = _campaign(repeats=1)
_start(campaign, tmp_path)
worker = FakeWorker()
campaign.attach_capture(worker, fps=10.0)
for _branch, target in list(campaign._steps):
_record_step(campaign, worker, target)
assert campaign.capture_state == "closing"
assert campaign.busy and not campaign.is_active
with pytest.raises(RuntimeError, match="already running"):
_start(campaign, tmp_path)
_drain(campaign)
# After the writer closed, a new session is allowed again.
second = _start(campaign, tmp_path)
assert second.is_dir()
def test_attaching_a_source_without_a_frame_raises_and_leaves_no_capture(tmp_path):
campaign = _campaign(repeats=1)
_start(campaign, tmp_path)
worker = FakeWorker()
worker.packet = None
with pytest.raises(RuntimeError, match="frame"):
campaign.attach_capture(worker, fps=10.0)
assert campaign.capture_state == "idle"
assert campaign._recorder is None
assert not (campaign.directory / "sites" / "P1" / "repeat-001" / "capture").exists()
def test_a_new_session_does_not_inherit_the_previous_source_or_rotation(tmp_path):
"""A live worker and a six-axis rotation from the last session must not leak."""
campaign = _campaign(repeats=1)
worker = FakeWorker()
campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="fixture", max_force_n=20, simulate=False,
)
campaign.set_force_rotation([1, 0, 0, 0, 1, 0, 0, 0, 1])
campaign.attach_capture(worker, fps=10.0)
campaign.stop("operator stopped")
_drain(campaign)
second = campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="fixture", max_force_n=20, simulate=True,
)
assert campaign._pending_worker is None
assert campaign._rotation is None
assert campaign.capture_state == "idle"
repeat = second / "sites" / "P1" / "repeat-001"
assert not (repeat / "capture").exists()
manifest = json.loads((repeat / "session.json").read_text(encoding="utf-8"))
assert manifest["images"]["captured"] is False
assert "force_to_tool_rotation" not in _session(second)
def test_a_repeat_directory_that_already_exists_is_never_reused(tmp_path):
campaign = _campaign(repeats=2)
root = _start(campaign, tmp_path)
# A leftover directory from an interrupted attempt must not be written into.
leftover = root / "sites" / "P1" / "repeat-002"
leftover.mkdir(parents=True)
with pytest.raises(RuntimeError, match="already exists"):
campaign._advance_after_repeat()
def test_the_confirm_button_waits_until_the_capture_is_recording(make_window, tmp_path):
"""A hold confirmed before the writer exists would be evidence with no video."""
from types import SimpleNamespace
from orisys_app_shared.i18n import t
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
worker = FakeWorker()
panel.source_panel.worker = worker
panel.source_panel.mode = SimpleNamespace(fps=10)
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
panel.output_path.setText(str(tmp_path))
panel.sensor_sn.setText("SN1")
# A started campaign whose capture was never attached: not ready.
multipoint.campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=False,
)
panel._sync()
assert multipoint.campaign.phase == "load"
assert not panel.confirm_button.isEnabled()
assert t("multipoint.capture_wait") in panel.target_label.text()
panel.reference.setText("0")
panel._confirm()
assert multipoint.campaign.phase == "load"
multipoint.campaign.attach_capture(worker, fps=10.0)
panel._sync()
assert panel.confirm_button.isEnabled()
assert multipoint.campaign.capture_ready
window.close()
def test_a_restarted_source_is_reported_on_the_panel(make_window, tmp_path):
from types import SimpleNamespace
from orisys_app_shared.i18n import t
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
worker = FakeWorker()
panel.source_panel.worker = worker
panel.source_panel.mode = SimpleNamespace(fps=10)
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
multipoint.campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=False,
)
multipoint.campaign.attach_capture(worker, fps=10.0)
multipoint.campaign.poll()
worker.restart()
worker.emit()
multipoint._poll()
_drain(multipoint.campaign)
multipoint._refresh()
assert multipoint.campaign.phase == "aborted"
assert t("multipoint.capture_incomplete") in multipoint.capture_status.text()
assert t("multipoint.capture_reason_restarted") in multipoint.capture_status.text()
manifest = json.loads(
(multipoint.campaign.directory / "sites" / "P1" / "repeat-001" / "session.json")
.read_text(encoding="utf-8")
)
assert manifest["capture_status"] == "incomplete"
assert manifest["capture_reason"] == "source_restarted"
window.close()
+265
View File
@@ -0,0 +1,265 @@
"""One window table over both recording formats, with nothing invented."""
from __future__ import annotations
import pytest
from evidence_helpers import (
BASE_NS,
FORCES_FIELDS,
FRAMES_FIELDS,
Hold,
HOLD_STRIDE_NS,
multipoint_session,
read_csv,
single_session,
write_csv,
)
from orisys_autocal.summary import (
HOLD_WINDOW_METHOD,
LEGACY_WINDOW_METHOD,
REASON_INSUFFICIENT_SAMPLES,
REASON_LEGACY,
REASON_NO_FRAME_SOURCE,
REASON_REMEASURED,
SCHEMA_VERSION,
build_summary,
legacy_window_rows,
)
def _levels() -> tuple[float, ...]:
return (5.0, 10.0)
def _means(summary: dict) -> list[float | None]:
return [row["FN_mean"] for row in summary["windows"]]
def test_both_formats_produce_the_same_identity_and_means(tmp_path):
multipoint = build_summary(multipoint_session(
tmp_path / "multi", sites=("P1",), repeats=1, levels=_levels()))
single = build_summary(single_session(
tmp_path / "single", sites=("site-001",), trials_per_site=1, levels=_levels()))
for summary in (multipoint, single):
assert summary["schema_version"] == SCHEMA_VERSION
assert summary["formal_conclusion"] is False
assert summary["units"]["raw"] == "raw"
assert summary["methods"]["fit"] == "raw_on_reference_ols_v1"
assert len(summary["windows"]) == 5
assert [row["branch"] for row in summary["windows"]] == [
"baseline", "load", "load", "unload", "unload"]
assert [row["hold_id"] for row in summary["windows"]] == [1, 2, 3, 4, 5]
assert all(row["session_id"] for row in summary["windows"])
assert all(row["trial_id"] for row in summary["windows"])
assert all(row["source_file"].endswith("reference.csv") for row in summary["windows"])
assert all(row["unit"] == "raw" for row in summary["windows"])
assert all(row["window_method"] == HOLD_WINDOW_METHOD for row in summary["windows"])
assert all(row["stability_verified"] is False for row in summary["windows"])
assert _means(multipoint) == pytest.approx([100.0, 300.0, 500.0, 300.0, 100.0])
assert _means(single) == pytest.approx(_means(multipoint))
assert [row["samples_valid"] for row in multipoint["windows"]] == [2, 2, 2, 2, 2]
assert all(row["capture_status"] == "unknown" for row in multipoint["windows"])
def test_a_six_axis_site_keeps_raw_components_apart(tmp_path):
summary = build_summary(multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
raw_base=100.0, raw_per_n=40.0))
load = summary["windows"][2]
assert load["FN_mean"] == pytest.approx(500.0)
assert load["FX_mean"] == pytest.approx(0.0)
assert load["FY_mean"] == pytest.approx(0.0)
assert load["FN_std"] == pytest.approx(0.0)
# The newton columns never appear in this table.
assert "FN_mean" in load and "FN_N" not in load
sensitivity = {row["component"]: row for row in summary["sensitivity"]}
assert sensitivity["FN"]["slope_raw_per_N"] == pytest.approx(40.0)
assert sensitivity["FN"]["status"] == "available"
def test_a_remeasured_hold_is_listed_with_its_reason(tmp_path):
replaced = Hold(
step_index=1, branch="load", target_load_n=5.0, reference_n=5.0,
start_ns=BASE_NS + 200 * HOLD_STRIDE_NS,
end_ns=BASE_NS + 200 * HOLD_STRIDE_NS + 4_000_000_000,
complete=0,
)
summary = build_summary(multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
extra_holds=[replaced]))
assert len(summary["windows"]) == 6
replaced_rows = [row for row in summary["windows"] if row["excluded_reason"] == REASON_REMEASURED]
assert len(replaced_rows) == 1
assert replaced_rows[0]["hold_id"] == 6
assert replaced_rows[0]["eligible"] is False
assert replaced_rows[0]["FN_mean"] is None
assert replaced_rows[0]["samples_total"] == 0
def test_a_missing_frame_source_is_reported_not_filled_in(tmp_path):
summary = build_summary(multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(),
with_capture=False))
assert all(row["eligible"] is False for row in summary["windows"])
assert all(row["excluded_reason"] == REASON_NO_FRAME_SOURCE for row in summary["windows"])
assert all(row["FN_mean"] is None for row in summary["windows"])
assert all(row["capture_status"] == "missing" for row in summary["windows"])
assert REASON_NO_FRAME_SOURCE in summary["missing_reasons"]
assert summary["sensitivity"] == []
def test_a_confirmed_window_uses_the_reviewed_interval(tmp_path):
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
from orisys_autocal.session_review import review_session
first = review_session(root)
window = first["sites"][0]["windows"][2]
selections = {f"{window['trial_id']}:{window['step_index']}": {
"t_start_host_mono_ns": window["hold_start_host_mono_ns"] + 1,
"t_end_host_mono_ns": window["hold_end_host_mono_ns"],
"stability_verified": True,
}}
summary = build_summary(root, selections=selections)
confirmed = [row for row in summary["windows"] if row["stability_verified"]]
assert len(confirmed) == 1
assert confirmed[0]["window_method"] == "review_selection_v1"
assert confirmed[0]["review_status"] == "confirmed"
assert confirmed[0]["t_start_host_mono_ns"] == window["hold_start_host_mono_ns"] + 1
assert confirmed[0]["samples_valid"] == 3
assert summary["selections_sha256"]
assert {row["review_status"] for row in summary["windows"] if not row["stability_verified"]} == {"candidate"}
def test_out_of_order_force_rows_do_not_change_the_means(tmp_path):
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
before = build_summary(root)
capture = root / "sites" / "P1" / "repeat-001" / "capture"
rows = read_csv(capture / "forces.csv")
write_csv(capture / "forces.csv", FORCES_FIELDS, list(reversed(rows)))
after = build_summary(root)
assert _means(after) == pytest.approx(_means(before))
def test_a_repeated_frame_identity_excludes_the_window_and_is_reported(tmp_path):
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
capture = root / "sites" / "P1" / "repeat-001" / "capture"
frames = read_csv(capture / "frames.csv")
forces = read_csv(capture / "forces.csv")
last_frames = frames[-3:]
last_forces = forces[-3:]
for frame, force in zip(last_frames, last_forces):
copy = dict(frame)
copy["t_host_mono_ns"] = int(frame["t_host_mono_ns"]) + 1
frames.append(copy)
copied = dict(force)
copied["FN_raw"] = repr(float(force["FN_raw"]) + 1.0)
forces.append(copied)
write_csv(capture / "frames.csv", FRAMES_FIELDS, frames)
write_csv(capture / "forces.csv", FORCES_FIELDS, forces)
summary = build_summary(root)
assert any(issue["reason"] == "duplicate_frame_identity" for issue in summary["issues"])
last = summary["windows"][-1]
assert last["samples_total"] == 0
assert last["excluded_reason"] == REASON_INSUFFICIENT_SAMPLES
assert last["eligible"] is False
# The earlier windows still have their own, unambiguous samples.
assert all(row["samples_valid"] == 2 for row in summary["windows"][:-1])
def test_two_sessions_are_never_merged(tmp_path):
first = build_summary(multipoint_session(
tmp_path / "a", sites=("P1",), repeats=1, levels=_levels()))
second = build_summary(multipoint_session(
tmp_path / "b", sites=("P2",), repeats=2, levels=(5.0,)))
assert {row["session_id"] for row in first["windows"]} == {first["session_id"]}
assert {row["session_id"] for row in second["windows"]} == {second["session_id"]}
assert first["session_id"] != second["session_id"]
assert len(second["windows"]) == 6 # baseline, load, unload per repeat
def test_an_old_stable_means_table_is_kept_but_never_fitted(tmp_path):
path = tmp_path / "stable_means.csv"
write_csv(path, [
"site_id", "U", "V", "负载类型", "repeat_index", "step_index", "branch",
"target_load_n", "samples", "FN_mean", "FX_mean", "FY_mean", "unit",
], [{
"site_id": "20261016point0", "U": "-145", "V": "604", "负载类型": "砝码",
"repeat_index": 1, "step_index": 1, "branch": "load", "target_load_n": 1.0,
"samples": 181, "FN_mean": 10013.7, "FX_mean": -6484.37, "FY_mean": 707.088,
"unit": "raw",
}])
rows = legacy_window_rows(path, summary_id="legacy-1")
assert len(rows) == 1
row = rows[0]
assert row["window_method"] == LEGACY_WINDOW_METHOD
assert row["eligible"] is False
assert row["excluded_reason"] == REASON_LEGACY
assert row["FN_mean"] == pytest.approx(10013.7)
assert row["t_start_host_mono_ns"] is None and row["FN_std"] is None
assert row["unit"] == "raw"
def test_the_table_states_what_it_did_not_conclude(tmp_path):
summary = build_summary(multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels()))
assert summary["formal_conclusion"] is False
assert summary["methods"]["window_unverified"] == HOLD_WINDOW_METHOD
assert summary["input_sha256"]
assert summary["software"]["numpy"]
assert summary["generated_at"].startswith("20")
assert summary["summary_id"]
assert all(row["summary_id"] == summary["summary_id"] for row in summary["windows"])
assert summary["sensitivity_summary"]
assert summary["sensitivity_summary"][0]["trials"] == 1
assert summary["sensitivity_summary"][0]["slope_std_raw_per_N"] is None
def test_a_sensitivity_row_exists_per_trial_and_review_set(tmp_path):
summary = build_summary(multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=2, levels=_levels()))
fn_rows = [row for row in summary["sensitivity"] if row["component"] == "FN"]
assert len(fn_rows) == 2
assert {row["review_status"] for row in fn_rows} == {"candidate"}
assert [row["window_count"] for row in fn_rows] == [3, 3]
assert all(row["status"] == "available" for row in fn_rows)
assert all(row["slope_raw_per_N"] == pytest.approx(40.0) for row in fn_rows)
assert all(row["endpoint_raw_per_N"] == pytest.approx(40.0) for row in fn_rows)
assert all(row["initial_step_raw"] == pytest.approx(200.0) for row in fn_rows)
def test_an_axis_without_samples_is_unavailable_without_blocking_the_others(tmp_path):
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels())
capture = root / "sites" / "P1" / "repeat-001" / "capture"
forces = read_csv(capture / "forces.csv")
for row in forces:
row["FX_raw"] = ""
write_csv(capture / "forces.csv", FORCES_FIELDS, forces)
summary = build_summary(root)
by_axis = {row["component"]: row for row in summary["sensitivity"]}
assert by_axis["FN"]["status"] == "available"
assert by_axis["FX"]["status"] == "no_data"
# A constant component has a unitless slope of zero and no R2; that is a
# flat signal, not a gain.
assert by_axis["FY"]["status"] == "flat_signal"
assert by_axis["FY"]["R2"] is None
assert by_axis["FY"]["slope_raw_per_N"] == pytest.approx(0.0)
assert summary["windows"][0]["axis_samples"]["FX"] == 0
+175
View File
@@ -0,0 +1,175 @@
"""Publishing a summary: one set of files, one pointer, never a partial result."""
from __future__ import annotations
import csv
import json
import pytest
from evidence_helpers import multipoint_session
from orisys_autocal.summary import build_summary
from orisys_autocal.summary_export import (
POINTER_FORMAT,
POINTER_NAME,
SummaryExportError,
load_summary,
publish_summary,
render_html,
)
@pytest.fixture
def session(tmp_path):
root = multipoint_session(
tmp_path / "rec", sites=("P1", "P2"), repeats=2, levels=(5.0, 10.0))
return root
def _pointer(root):
return json.loads((root / POINTER_NAME).read_text(encoding="utf-8"))
def _read_csv(path):
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def test_publishing_writes_all_four_files_and_moves_the_pointer(session):
summary = build_summary(session)
published = publish_summary(session, summary)
assert published.is_dir()
assert published.name == f"summary_{summary['summary_id']}"
for name in ("summary.json", "summary.html", "baseline_load_data.csv",
"sensitivity_values.csv"):
assert (published / name).is_file(), name
pointer = _pointer(session)
assert pointer["format_version"] == POINTER_FORMAT
assert pointer["summary_id"] == summary["summary_id"]
assert pointer["path"] == f"derived/{published.name}"
assert set(pointer["files"]) == {
"summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"}
loaded, pointer_again = load_summary(session)
assert loaded["summary_id"] == summary["summary_id"]
assert pointer_again["summary_id"] == summary["summary_id"]
# The export is a snapshot of the same object, not a second computation.
stored = json.loads((published / "summary.json").read_text(encoding="utf-8"))
assert stored["windows"] == summary["windows"]
assert stored["sensitivity"] == summary["sensitivity"]
def test_the_html_is_self_contained_and_escapes_operator_text(session):
summary = build_summary(session)
summary = dict(summary)
summary["sensor_sn"] = "<script>alert('x')</script>"
document = render_html(summary)
assert "<script>" not in document
assert "&lt;script&gt;" in document
assert "http://" not in document and "https://" not in document
assert "not a pass/fail certificate" in document
def test_a_write_failure_leaves_the_previous_pointer_in_place(session, monkeypatch):
import orisys_autocal.summary_export as module
first = build_summary(session)
publish_summary(session, first)
before = (session / POINTER_NAME).read_text(encoding="utf-8")
second = build_summary(session)
second["generated_at"] = "2999-01-01T00:00:00+00:00"
def explode(*args, **kwargs):
raise OSError("no space left on device")
monkeypatch.setattr(module, "_write_csv", explode)
with pytest.raises(SummaryExportError, match="could not publish"):
publish_summary(session, second)
assert (session / POINTER_NAME).read_text(encoding="utf-8") == before
assert not (session / "derived" / f"summary_{second['summary_id']}").exists()
leftovers = [path for path in (session / "derived").iterdir() if path.name.startswith(".tmp-")]
assert leftovers == []
def test_the_pointer_never_moves_back_to_an_older_summary(session):
first = build_summary(session)
first["generated_at"] = "2026-10-18T00:00:00+00:00"
publish_summary(session, first)
older = build_summary(session)
older["generated_at"] = "2026-10-17T00:00:00+00:00"
with pytest.raises(SummaryExportError, match="back"):
publish_summary(session, older)
assert _pointer(session)["summary_id"] == first["summary_id"]
def test_changed_inputs_refuse_to_publish(session):
summary = build_summary(session)
forces = session / "sites" / "P1" / "repeat-001" / "capture" / "forces.csv"
text = forces.read_text(encoding="utf-8-sig")
forces.write_text(text + "\n", encoding="utf-8")
with pytest.raises(SummaryExportError, match="changed"):
publish_summary(session, summary)
assert not (session / POINTER_NAME).exists()
def test_a_cancelled_export_writes_nothing(session):
summary = build_summary(session)
with pytest.raises(SummaryExportError, match="cancelled"):
publish_summary(session, summary, cancelled=lambda: True)
assert not (session / POINTER_NAME).exists()
assert not (session / "derived").exists()
def test_the_same_summary_is_not_published_twice(session):
summary = build_summary(session)
publish_summary(session, summary)
with pytest.raises(SummaryExportError, match="already published"):
publish_summary(session, summary)
def test_load_refuses_a_tampered_export(session):
summary = build_summary(session)
published = publish_summary(session, summary)
(published / "sensitivity_values.csv").write_text("changed", encoding="utf-8")
with pytest.raises(SummaryExportError, match="modified"):
load_summary(session)
def test_the_csv_keeps_full_precision_and_utf8_text(session):
summary = build_summary(session)
summary["windows"][1]["FN_mean"] = 40.1234567890123
summary["windows"][1]["site_id"] = "测点一"
published = publish_summary(session, summary)
rows = _read_csv(published / "baseline_load_data.csv")
assert rows[1]["FN_mean"] == repr(40.1234567890123)
assert float(rows[1]["FN_mean"]) == pytest.approx(40.1234567890123)
assert rows[1]["site_id"] == "测点一"
assert rows[1]["unit"] == "raw"
assert rows[1]["window_method"] == "hold_second_half_v1"
assert rows[1]["eligible"] == "1"
assert rows[1]["samples_FN"] == "2"
def test_the_sensitivity_csv_has_one_row_per_point_trial_axis(session):
summary = build_summary(session)
published = publish_summary(session, summary)
rows = _read_csv(published / "sensitivity_values.csv")
assert len(rows) == len(summary["sensitivity"]) == 2 * 2 * 3
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
assert {row["method"] for row in rows} == {"raw_on_reference_ols_v1"}
assert {row["review_status"] for row in rows} == {"candidate"}
fn = [row for row in rows if row["component"] == "FN"]
assert all(float(row["slope_raw_per_N"]) == pytest.approx(40.0) for row in fn)
+166
View File
@@ -0,0 +1,166 @@
"""The summary fit is a forward regression of raw on the declared reference.
Hand-computed lines, one nonlinear set, and the proof that its slope is not the
reciprocal of the review's inverse fit (``N = gain * raw + offset``).
"""
from __future__ import annotations
import math
import pytest
from orisys_autocal.summary import fit_axis
def _window(load: float, fn: float | None, *, branch: str = "load", verified: bool = False,
eligible: bool = True, site: str = "P1", trial: str = "t1") -> dict:
return {
"session_id": "session-1", "site_id": site, "trial_id": trial,
"repeat_index": 1, "step_index": 0, "branch": branch,
"reference_load_n": load, "eligible": eligible,
"stability_verified": verified,
"FN_mean": fn, "FX_mean": 0.0, "FY_mean": 0.0,
}
def test_a_straight_line_is_recovered_exactly():
fit = fit_axis([(0.0, 100.0), (2.0, 180.0), (4.0, 260.0)])
assert fit["status"] == "available"
assert fit["slope_raw_per_N"] == pytest.approx(40.0)
assert fit["intercept_raw"] == pytest.approx(100.0)
assert fit["R2"] == pytest.approx(1.0)
assert fit["rmse_raw"] == pytest.approx(0.0)
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 4.0
assert fit["window_count"] == 3
def test_a_nonlinear_set_matches_the_hand_computation():
# x mean 1, y mean 166.667; slope 80; intercept 86.667; SSE 1066.667;
# SST 13866.667 -> R2 1 - 1066.667 / 13866.667 = 0.923077.
fit = fit_axis([(0.0, 100.0), (1.0, 140.0), (2.0, 260.0)])
assert fit["slope_raw_per_N"] == pytest.approx(80.0)
assert fit["intercept_raw"] == pytest.approx(86.6666666667, rel=1e-9)
assert fit["R2"] == pytest.approx(0.9230769231, rel=1e-9)
assert fit["rmse_raw"] == pytest.approx(math.sqrt(1066.6666667 / 3), rel=1e-9)
def test_a_constant_signal_has_no_coefficient_of_determination():
fit = fit_axis([(0.0, 5.0), (1.0, 5.0), (2.0, 5.0)])
assert fit["status"] == "flat_signal"
assert fit["R2"] is None
assert fit["slope_raw_per_N"] == pytest.approx(0.0)
assert fit["intercept_raw"] == pytest.approx(5.0)
def test_one_reference_level_cannot_be_fitted():
fit = fit_axis([(5.0, 300.0), (5.0, 310.0)])
assert fit["status"] == "insufficient_reference_levels"
assert "slope_raw_per_N" not in fit
def test_no_finite_sample_is_reported_as_missing_data():
fit = fit_axis([])
assert fit["status"] == "no_data"
def test_repeated_reference_levels_are_weighted_but_counted():
fit = fit_axis([(0.0, 100.0), (5.0, 300.0), (5.0, 320.0), (10.0, 520.0)])
assert fit["window_count"] == 4
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 10.0
assert fit["slope_raw_per_N"] == pytest.approx(42.0)
def test_the_forward_slope_is_not_the_reciprocal_of_the_inverse_fit():
"""``N = gain*raw + offset`` and ``raw = a*N + b`` answer different questions."""
import numpy as np
from orisys_autocal.session_review import _fit
raw = [100.0, 300.0, 520.0]
loads = [0.0, 5.0, 10.0]
forward = fit_axis(list(zip(loads, raw)))
inverse = _fit([
{"raw_mean": value, "reference_n": load, "status": "candidate", "trial_id": "t1"}
for value, load in zip(raw, loads)
])
gain, _offset = np.polyfit(np.array(raw), np.array(loads), 1)
assert forward["slope_raw_per_N"] == pytest.approx(42.0)
assert inverse["sensitivity_raw_per_n"] == pytest.approx(1.0 / gain)
assert abs(forward["slope_raw_per_N"] - 1.0 / gain) > 1e-3
def test_unloading_is_shown_but_never_fitted():
from orisys_autocal.summary import build_sensitivity
windows = [
_window(0.0, 100.0, branch="baseline"),
_window(5.0, 300.0),
_window(5.0, 340.0, branch="unload"),
_window(0.0, 120.0, branch="unload"),
]
rows = build_sensitivity(windows)
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
fn = next(row for row in rows if row["component"] == "FN")
assert fn["window_count"] == 2
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
# The unloading values are far higher; a fit that included them would not be 40.
assert fn["endpoint_raw_per_N"] == pytest.approx(40.0)
def test_endpoint_and_initial_step_use_the_declared_loads():
from orisys_autocal.summary import build_sensitivity
windows = [
_window(0.0, 100.0, branch="baseline"),
_window(2.0, 180.0),
_window(5.0, 300.0),
]
fn = next(row for row in build_sensitivity(windows) if row["component"] == "FN")
# endpoint: (raw at the highest load - raw at zero) / highest load.
assert fn["endpoint_raw_per_N"] == pytest.approx((300.0 - 100.0) / 5.0)
# initial step: the lowest positive load minus zero.
assert fn["initial_step_raw"] == pytest.approx(80.0)
def test_a_missing_zero_load_leaves_the_derived_numbers_empty():
from orisys_autocal.summary import build_sensitivity
fn = next(row for row in build_sensitivity([_window(5.0, 300.0), _window(10.0, 500.0)])
if row["component"] == "FN")
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
assert fn["endpoint_raw_per_N"] is None
assert fn["initial_step_raw"] is None
def test_confirmed_and_candidate_windows_are_never_fitted_together():
from orisys_autocal.summary import build_sensitivity
windows = [
_window(0.0, 100.0, branch="baseline"),
_window(5.0, 300.0),
_window(0.0, 100.0, branch="baseline", verified=True),
_window(10.0, 500.0, verified=True),
]
rows = build_sensitivity(windows)
by_review = {}
for row in rows:
if row["component"] == "FN":
by_review[row["review_status"]] = row
assert set(by_review) == {"candidate", "confirmed"}
assert by_review["candidate"]["window_count"] == 2
assert by_review["candidate"]["slope_raw_per_N"] == pytest.approx(40.0)
assert by_review["confirmed"]["window_count"] == 2
assert by_review["confirmed"]["slope_raw_per_N"] == pytest.approx(40.0)
@@ -0,0 +1,150 @@
"""The candidate export, the reviewed export, and what happens when it fails."""
from __future__ import annotations
import hashlib
import time
from PySide6.QtCore import QCoreApplication
from review_test_helpers import wait_review
from evidence_helpers import multipoint_session
from orisys_autocal.summary_export import POINTER_NAME, load_summary
def _pump(panel, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout
while panel.summary_publishing and time.monotonic() < deadline:
QCoreApplication.processEvents()
time.sleep(0.005)
assert not panel.summary_publishing, "the summary export never finished"
def _finish_simulated_campaign(campaign) -> None:
while campaign.phase in {"load", "hold"}:
if campaign.phase == "load":
branch, target = campaign._steps[campaign._step_index]
campaign.confirm_reference(target)
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
def _acquired_hashes(root) -> dict[str, str]:
"""Hashes of the recorded evidence only: derived results are not evidence."""
return {
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in root.rglob("*")
if path.is_file() and "derived" not in path.parts and path.name != POINTER_NAME
}
def test_closing_the_session_publishes_a_candidate_summary(make_window, tmp_path):
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
before = _acquired_hashes(root)
window = make_window()
panel = window.calibration_panel
assert panel.publish_summary_for(root)
_pump(panel)
assert (root / POINTER_NAME).is_file()
summary, pointer = load_summary(root)
assert summary["formal_conclusion"] is False
assert summary["windows"]
assert all(row["review_status"] == "candidate" for row in summary["windows"])
assert pointer["path"].startswith("derived/summary_")
# Raw evidence is never rewritten by an export.
assert _acquired_hashes(root) == before
window.close()
def test_a_failed_export_is_reported_and_can_be_retried(
make_window, tmp_path, monkeypatch
):
import orisys_autocal.ui.calibration_panel as panel_module
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,))
window = make_window()
panel = window.calibration_panel
def explode(*args, **kwargs):
raise RuntimeError("disk full")
monkeypatch.setattr(panel_module, "publish_summary", explode)
assert panel.publish_summary_for(root)
_pump(panel)
assert not (root / POINTER_NAME).exists()
assert "disk full" in panel.result_label.text()
assert panel.trial.status == "idle" # the acquisition result is untouched
monkeypatch.undo()
assert panel.publish_summary_for(root)
_pump(panel)
assert (root / POINTER_NAME).is_file()
window.close()
def test_a_published_candidate_is_republished_when_the_review_is_saved(qt_app, tmp_path):
from orisys_autocal.session_review import review_session
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
review = review_session(root)
window = review["sites"][0]["windows"][2]
selections = {f"{window['trial_id']}:{window['step_index']}": {
"t_start_host_mono_ns": window["hold_start_host_mono_ns"] + 1,
"t_end_host_mono_ns": window["hold_end_host_mono_ns"],
"stability_verified": True,
}}
reviewed = review_session(root, selections=selections)
dialog = RecordOverviewDialog(root, review=reviewed)
try:
dialog._save()
wait_review(dialog)
assert (root / POINTER_NAME).is_file()
summary, pointer = load_summary(root)
confirmed = [row for row in summary["windows"] if row["stability_verified"]]
assert confirmed and confirmed[0]["review_status"] == "confirmed"
assert pointer["summary_id"] == summary["summary_id"]
assert summary["review_relative_path"].startswith("derived/review_")
assert summary["sensitivity"]
# A second explicit publish (the retry action) makes a new version.
first_id = pointer["summary_id"]
dialog._publish_summary()
wait_review(dialog)
second, pointer_again = load_summary(root)
assert pointer_again["summary_id"] != first_id
assert second["summary_id"] == pointer_again["summary_id"]
finally:
dialog.close()
def test_the_multipoint_end_of_campaign_starts_the_export(make_window, tmp_path):
window = make_window()
panel = window.calibration_panel
multipoint = panel.multipoint
multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20)
directory = multipoint.campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20, simulate=True,
)
_finish_simulated_campaign(multipoint.campaign)
assert multipoint.campaign.phase == "done"
multipoint._poll()
_pump(panel)
assert (directory / POINTER_NAME).is_file()
summary, _pointer = load_summary(directory)
assert summary["session_id"] == directory.name
# A simulated campaign produces no frames, so its windows must not read as
# measurements: they are excluded with the reason the review recorded.
assert all(row["eligible"] is False for row in summary["windows"])
assert all(row["excluded_reason"] == "no_frame_source" for row in summary["windows"])
assert any(issue["reason"] == "no_frame_source" for issue in summary["issues"])
window.close()
+246
View File
@@ -0,0 +1,246 @@
"""Locating a reviewed hold's frames in the existing recording."""
from __future__ import annotations
import pytest
from evidence_helpers import (
BASE_NS,
FRAMES_FIELDS,
decoded_frame_number,
force_row,
frame_row,
multipoint_session,
write_capture,
write_csv,
write_json_file,
)
from orisys_autocal.session_review import review_session
from orisys_autocal.video_review import (
NO_FRAME_SOURCE,
NO_VIDEO_FRAME,
Location,
in_window_frames,
locate_window,
open_window_video,
)
def _review(tmp_path) -> dict:
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,))
return review_session(root)
def _capture(review: dict):
trial_id = review["trials"][0]["trial_id"]
step = review["sites"][0]["windows"][1]["step_index"]
return trial_id, step
def test_a_reviewed_window_locates_its_own_first_video_frame(tmp_path):
review = _review(tmp_path)
trial_id, step = _capture(review)
location = locate_window(review, trial_id=trial_id, step_index=step)
assert isinstance(location, Location)
assert location.frame_id > 0
assert location.segment == 1 and location.segment_frame == location.frame_id
# The located picture is the one the frame index names.
root = tmp_path / "rec"
video = root / location.capture_directory / "raw_0001.mp4"
assert decoded_frame_number(video, segment_frame=location.segment_frame) == location.segment_frame - 1
with open_window_video(review, trial_id=trial_id, step_index=step) as player:
assert player.frame_count >= 1
assert player.frame(0) is not None
assert player.location.frame_id == location.frame_id
assert player.frame(0).shape[:2] == (48, 64)
def test_a_frame_outside_the_video_is_skipped_for_the_next_one(tmp_path):
review = _review(tmp_path)
root = tmp_path / "rec"
trial_id = review["trials"][0]["trial_id"]
window = review["sites"][0]["windows"][1]
capture = root / "sites" / "P1" / "repeat-001" / "capture"
from evidence_helpers import read_csv
rows = read_csv(capture / "frames.csv")
# The second hold starts at frame 4; take its first frame out of the video.
first_of_hold = int(window["hold_start_host_mono_ns"])
row = next(item for item in rows if int(item["t_host_mono_ns"]) >= first_of_hold)
row["in_video"] = 0
row["segment"] = 0
row["segment_frame"] = 0
skipped = int(row["frame_id"])
write_csv(capture / "frames.csv", FRAMES_FIELDS, rows)
location = locate_window(review, trial_id=trial_id, step_index=window["step_index"])
assert isinstance(location, Location)
assert location.frame_id == skipped + 1
def test_no_capture_directory_is_a_reason_not_a_picture(tmp_path):
root = multipoint_session(tmp_path / "rec", sites=("P1",), repeats=1,
levels=(5.0,), with_capture=False)
review = review_session(root)
trial_id = review["trials"][0]["trial_id"]
step = review["sites"][0]["windows"][1]["step_index"]
location = locate_window(review, trial_id=trial_id, step_index=step)
assert location == {"reason": NO_FRAME_SOURCE}
with pytest.raises(ValueError, match=NO_FRAME_SOURCE):
open_window_video(review, trial_id=trial_id, step_index=step)
def test_a_hold_with_no_picture_in_the_video_has_no_frame(tmp_path):
review = _review(tmp_path)
root = tmp_path / "rec"
capture = root / "sites" / "P1" / "repeat-001" / "capture"
from evidence_helpers import read_csv
rows = read_csv(capture / "frames.csv")
for row in rows:
row["in_video"] = 0
row["segment"] = 0
row["segment_frame"] = 0
write_csv(capture / "frames.csv", FRAMES_FIELDS, rows)
trial_id, step = _capture(review)
assert locate_window(review, trial_id=trial_id, step_index=step) == {
"reason": NO_VIDEO_FRAME}
def test_a_missing_segment_file_is_reported(tmp_path):
review = _review(tmp_path)
root = tmp_path / "rec"
for video in root.glob("sites/*/*/capture/raw_0001.mp4"):
video.unlink()
trial_id, step = _capture(review)
assert locate_window(review, trial_id=trial_id, step_index=step) == {
"reason": NO_FRAME_SOURCE}
def test_a_hold_split_across_segments_opens_the_right_one(tmp_path):
"""Segment 2 restarts its own frame count; seeking must follow the index."""
root = tmp_path / "rec"
trial = root / "trial"
start = BASE_NS + 10
frames = [
frame_row(1, start, segment=1, segment_frame=1),
frame_row(2, start + 10, segment=1, segment_frame=2),
frame_row(3, start + 20, segment=2, segment_frame=1),
]
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
write_capture(trial, frames, forces, video_segments=2)
write_json_file(root / "session.json", {"format_version": "autocal-session-1.0"})
review = {
"source_directory": str(root),
"session_id": "s1",
"trials": [{"trial_id": "t1", "directory": "trial"}],
"sites": [{
"site_id": "P1", "uv_xy_px": [1, 2],
"windows": [{
"trial_id": "t1", "step_index": 1,
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": start + 100,
"frame_source": "trial",
"trace": [(start, 1, 1.0), (start + 10, 2, 2.0), (start + 20, 3, 3.0)],
}],
}],
}
location = locate_window(review, trial_id="t1", step_index=1)
assert isinstance(location, Location) and location.segment == 1
with open_window_video(review, trial_id="t1", step_index=1) as player:
assert player.frame_count == 3
assert player.frame(0) is not None
# Frame 3 lives in the second container, at its own position 1.
assert player.frame(2) is not None
assert player.frames[2].segment == 2 and player.frames[2].segment_frame == 1
def test_the_decoder_can_be_closed_and_reopened(tmp_path):
review = _review(tmp_path)
trial_id, step = _capture(review)
first = open_window_video(review, trial_id=trial_id, step_index=step)
assert first.frame(0) is not None
first.close()
second = open_window_video(review, trial_id=trial_id, step_index=step)
try:
assert second.frame(0) is not None
finally:
second.release()
def test_an_unknown_trial_or_window_is_a_reason(tmp_path):
review = _review(tmp_path)
trial_id, step = _capture(review)
assert locate_window(review, trial_id="missing", step_index=step) == {"reason": "trial_not_found"}
assert locate_window(review, trial_id=trial_id, step_index=999) == {"reason": "window_not_found"}
def test_the_frame_list_is_bounded_by_the_hold(tmp_path):
review = _review(tmp_path)
trial_id, step = _capture(review)
window = next(
item for site in review["sites"] for item in site["windows"]
if item["trial_id"] == trial_id and item["step_index"] == step
)
frames = in_window_frames(review, trial_id=trial_id, step_index=step)
assert isinstance(frames, list) and frames
assert all(
window["hold_start_host_mono_ns"] <= frame.t_host_mono_ns < window["hold_end_host_mono_ns"]
for frame in frames
)
assert all(frame.in_video for frame in frames)
def test_decode_failure_is_reported_rather_than_showing_a_neighbour(tmp_path):
review = _review(tmp_path)
root = tmp_path / "rec"
trial_id, step = _capture(review)
location = locate_window(review, trial_id=trial_id, step_index=step)
assert isinstance(location, Location)
(root / location.capture_directory / "raw_0001.mp4").write_bytes(b"not a video")
window = review["sites"][0]["windows"][1]
player = open_window_video(review, trial_id=trial_id, step_index=window["step_index"])
try:
assert player.frame(0) is None
assert player.position == -1
finally:
player.close()
def test_a_discarded_hold_attempt_cannot_be_played(tmp_path):
"""A remeasured hold keeps its row id; its window must not be shown as evidence."""
from evidence_helpers import HOLD_STRIDE_NS, Hold, multipoint_session
replaced = Hold(
step_index=1, branch="load", target_load_n=5.0, reference_n=5.0,
start_ns=BASE_NS + 200 * HOLD_STRIDE_NS,
end_ns=BASE_NS + 200 * HOLD_STRIDE_NS + 4_000_000_000,
complete=0,
)
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), extra_holds=[replaced])
review = review_session(root)
trial_id = review["trials"][0]["trial_id"]
step = next(item for site in review["sites"] for item in site["windows"]
if item["trial_id"] == trial_id)["step_index"]
assert locate_window(review, trial_id=trial_id, step_index=step, hold_id=4) == {
"reason": "hold_not_complete"}
assert locate_window(review, trial_id=trial_id, step_index=step, hold_id=99) == {
"reason": "hold_not_found"}
# The completed attempt still opens, and names the attempt it shows.
location = locate_window(review, trial_id=trial_id, step_index=step, hold_id=1)
assert isinstance(location, Location) and location.hold_id == 1
@@ -0,0 +1,149 @@
"""The review dialog that shows the recorded pictures beside the curve."""
from __future__ import annotations
import pytest
from review_test_helpers import wait_review
from evidence_helpers import multipoint_session
from orisys_autocal.session_review import review_session
@pytest.fixture
def reviewed(tmp_path):
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0, 10.0))
return root, review_session(root)
def _window(review: dict) -> dict:
return review["sites"][0]["windows"][1]
def test_the_dialog_shows_the_first_frame_of_the_selected_window(qt_app, reviewed):
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root, review = reviewed
window = _window(review)
dialog = VideoReviewDialog(
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
try:
wait_review(dialog)
assert dialog.video is not None
assert dialog.location.frame_id > 0
assert dialog.image.pixmap() is not None and not dialog.image.pixmap().isNull()
assert "frame" in dialog.position.text().lower() or "帧" in dialog.position.text()
assert dialog.slider.maximum() == dialog.video.frame_count - 1
finally:
dialog.close()
assert dialog.video is None
def test_stepping_moves_one_recorded_frame(qt_app, reviewed):
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root, review = reviewed
window = _window(review)
dialog = VideoReviewDialog(
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
try:
wait_review(dialog)
start = dialog.index
dialog._step(1)
wait_review(dialog)
assert dialog.index == start + 1
dialog._step(-1)
wait_review(dialog)
assert dialog.index == start
finally:
dialog.close()
def test_a_stale_frame_request_is_dropped(qt_app, reviewed):
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root, review = reviewed
window = _window(review)
dialog = VideoReviewDialog(
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
try:
wait_review(dialog)
dialog._request(0)
dialog._request(1)
wait_review(dialog)
assert dialog.index == 1
assert dialog.video.frame_count >= 2
finally:
dialog.close()
def test_a_recording_without_video_explains_itself(qt_app, tmp_path):
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), with_capture=False)
review = review_session(root)
window = review["sites"][0]["windows"][1]
dialog = VideoReviewDialog(
root, review, trial_id=window["trial_id"], step_index=int(window["step_index"]))
try:
wait_review(dialog)
assert dialog.video is None
assert not dialog.play_button.isEnabled()
assert dialog.image.text()
assert "no" in dialog.image.text().lower() or "没有" in dialog.image.text()
finally:
dialog.close()
def test_the_hold_review_dialog_offers_the_video_for_the_selected_window(qt_app, reviewed):
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root, review = reviewed
dialog = SessionReviewDialog(root, review=review)
try:
assert dialog.video_button.isEnabled()
opened = dialog._video()
assert isinstance(opened, VideoReviewDialog)
wait_review(opened)
opened.close()
finally:
dialog.close()
def test_the_record_overview_offers_the_video_action(qt_app, reviewed):
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
from orisys_autocal.ui.video_review_dialog import VideoReviewDialog
root, review = reviewed
dialog = RecordOverviewDialog(root, review=review)
try:
actions = [action.objectName() for action in dialog.tools.menu().actions()]
assert "overview_video" in actions
dialog.table.clearSelection()
dialog.table.setCurrentCell(-1, -1)
assert dialog._video() is None
assert dialog.notice.text()
dialog.table.selectRow(0)
opened = dialog._video()
assert isinstance(opened, VideoReviewDialog)
wait_review(opened)
opened.close()
finally:
dialog.close()
def test_an_unclosed_trial_does_not_offer_the_video(qt_app, tmp_path):
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
root = multipoint_session(
tmp_path / "rec", sites=("P1",), repeats=1, levels=(5.0,), trial_status="incomplete")
review = review_session(root)
dialog = SessionReviewDialog(root, review=review)
try:
assert dialog._video() is None
assert dialog.message.text()
finally:
dialog.close()