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:
@@ -392,7 +392,22 @@
|
||||
"col_levels": "Levels (N)",
|
||||
"col_hold": "Hold (s)",
|
||||
"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": {
|
||||
"title": "Finger calibration-point debug",
|
||||
@@ -589,6 +604,7 @@
|
||||
"overview": {
|
||||
"capture_in_progress": "In progress",
|
||||
"saved": "Analysis saved; hover for its location.",
|
||||
"saved_summary": "Review saved and summary published: {path} → {summary}",
|
||||
"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.",
|
||||
"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_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_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": {
|
||||
"open": "Repeatability and validation…",
|
||||
@@ -724,6 +741,7 @@
|
||||
"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.",
|
||||
"saved": "Saved new review: {path}",
|
||||
"saved_summary": "Review saved and summary published: {path} → {summary}",
|
||||
"col_trial": "Trial",
|
||||
"col_branch": "Phase",
|
||||
"col_load": "Reference (N)",
|
||||
@@ -733,5 +751,35 @@
|
||||
"col_duration": "Hold (s)",
|
||||
"col_quality": "Quality",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,22 @@
|
||||
"col_levels": "档位 (N)",
|
||||
"col_hold": "保持 (s)",
|
||||
"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": {
|
||||
"title": "手指标定点调试",
|
||||
@@ -589,6 +604,7 @@
|
||||
"overview": {
|
||||
"capture_in_progress": "采集中",
|
||||
"saved": "分析结果已保存;保存位置见提示。",
|
||||
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
|
||||
"open_session": "打开标定会话…",
|
||||
"empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。",
|
||||
"title": "标定记录总览",
|
||||
@@ -654,7 +670,8 @@
|
||||
"guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。",
|
||||
"guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。",
|
||||
"guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。",
|
||||
"guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。"
|
||||
"guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。",
|
||||
"video": "查看录像"
|
||||
},
|
||||
"quality": {
|
||||
"open": "重复性与验证…",
|
||||
@@ -724,6 +741,7 @@
|
||||
"uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合",
|
||||
"issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。",
|
||||
"saved": "已另存:{path}",
|
||||
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
|
||||
"col_trial": "试验",
|
||||
"col_branch": "阶段",
|
||||
"col_load": "参考力 (N)",
|
||||
@@ -733,5 +751,35 @@
|
||||
"col_duration": "保持 (s)",
|
||||
"col_quality": "质量说明",
|
||||
"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": "这个保持尝试已被重测废弃,没有可复核的画面"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,14 @@ finger_F001_<时间>_<标识>/
|
||||
| 文件 | 内容及写入时机 |
|
||||
|---|---|
|
||||
| 根 `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;不等于来源视频 |
|
||||
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
|
||||
| `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 |
|
||||
| `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 |
|
||||
| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 |
|
||||
| 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 |
|
||||
| `derived/summary_*/`、`summary-latest.json` | 汇总四件文件与最后成功发布的指针;字段见[多点格式](multipoint-data-format.zh.md) |
|
||||
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable |
|
||||
| `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 的窗口不能用于正式拟合 |
|
||||
| `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 负责文件写入 |
|
||||
| 完整 trial + 帧身份/电脑时间 → 人工复核窗口 → 加载/卸载、重复性、trial 留出验证及 UV 着色 | Autocal 初步分析;实机验收后置 |
|
||||
|
||||
主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。
|
||||
主时间是电脑取得该帧时绑定的单调时间,UTC 仅供追溯,处理完成时间不能代替采集时间。FT/ECAT 后续按同一接收时间关联,不按行号配对。`host_replay` 只是本次解码时间,不能恢复原实验同步。
|
||||
|
||||
“标定记录”页打开会话,核对型号/SN、时间、来源和采集状态。选点及单次试验,先看时间曲线及诊断,再“检查保持段…”,最后“保存分析结果”。采集页“查看标定数据…”也跳转至此。
|
||||
“标定记录”页打开会话,核对型号/SN、来源和采集状态;选点及单次试验先看时间曲线,再“检查保持段…”,最后保存分析结果。采集页“查看标定数据…”也跳转至此。
|
||||
|
||||
| 总览 | 规则 |
|
||||
|---|---|
|
||||
@@ -126,16 +134,16 @@ finger_F001_<时间>_<标识>/
|
||||
|
||||
拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 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)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Autocal 多点标定会话格式(`autocal-multipoint-1.0`)
|
||||
|
||||
状态:2026-10-14 记录格式与只读复核已接通,仅软件验证,待实机验收。坐标与载荷约定、finger
|
||||
状态:2026-10-17 记录格式、只读复核、录像完整性判定与操作员汇总已接通,仅软件验证,待实机验收。坐标与载荷约定、finger
|
||||
会话格式见[标定操作与数据格式](calibration-data-format.zh.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 |
|
||||
| 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` |
|
||||
| `events.csv` | 电脑单调时间、UTC、事件类型与明细 |
|
||||
| `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` 列,该标志只由复核时选中的子区间产生 |
|
||||
| 参考力 | 称重模式是操作员输入的法向力;六轴模式是 `F_ref_normal_N = (R @ F) · tool_axis`,`R` 为本次会话输入力到工具的旋转。两者都是人工声明的参考,不是独立测量,也不能替代 FT |
|
||||
| 时间关联 | 帧属于某个保持窗口,当且仅当它的 `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`);执行前按当前安装设置重新求解,记录值只作对照 |
|
||||
| 目录名 | 会话目录带型号、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` 分流,多点会话走
|
||||
@@ -59,3 +72,21 @@ sites/<site_id>/repeat-00N/
|
||||
多点复核的 `formal_conclusion` 恒为 `false`:参考力是人工声明,帧来源可能缺失,格式里也没有独立
|
||||
验证。结果只写 `derived/review_<时间>_<标识>/review.json`,原始文件哈希不变;未接入 FT/ECAT
|
||||
前不写回 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 补造证据。
|
||||
|
||||
导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针。原始文件、历史 review 和已发布 summary 均不覆盖。
|
||||
导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针(取消由调用方传入,界面暂未提供取消按钮)。原始文件、历史 review 和已发布 summary 均不覆盖。
|
||||
|
||||
## 录像身份、时间和生命周期
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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),离线测试全绿不代表硬件验收。
|
||||
|
||||
## 现状与问题
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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 和帧身份可用字段;冻结输入输出,避免接口臆造。
|
||||
- [ ] 建 `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 私有代码。
|
||||
|
||||
@@ -67,26 +67,40 @@ dd02-gl 的 60/15 行与重复 site_id 必须记录为历史案例;测试用
|
||||
|
||||
## D:集成与独立验收(2–3 日)
|
||||
|
||||
- [ ] D1:主代理修改 `multipoint.py`、`trial.py`、`ui/record_overview_dialog.py` 与 locale,将采集终态触发候选导出、保存 review 触发新汇总串起来;导出失败可重试且不改采集结果。加入 `test_summary_lifecycle.py`。
|
||||
- [ ] D1 后同步三份既有格式/实施说明和新契约状态,注册文档;不把计划全文复制到多个位置。
|
||||
- [ ] D2:换一名只读验收代理检查完整流程及失败场景,核对原始文件哈希、录像归属、结果数字和旧消费者。主代理处理发现并复跑相关检查。
|
||||
- [x] D1:主代理修改 `multipoint.py`、`trial.py`、`ui/record_overview_dialog.py` 与 locale,将采集终态触发候选导出、保存 review 触发新汇总串起来;导出失败可重试且不改采集结果。加入 `test_summary_lifecycle.py`。
|
||||
- [x] D1 后同步三份既有格式/实施说明和新契约状态,注册文档;不把计划全文复制到多个位置。
|
||||
- [x] D2:一名只读验收代理独立复核(562 项实跑 exit 0);提出 3 项 major、9 项 minor,主代理已修 3 项 major 与 6 项 minor,其余记为未接线或已接受。
|
||||
|
||||
首个检查点在 A2/B2/C1 后:身份、收尾、公式全部通过才接界面。最终检查点:新进程打开两种会话,选择已确认窗口播放,打开报告及两 CSV,中止/缺视频能解释原因。任何必需环境缺失或测试跳过都列为未验证。
|
||||
|
||||
## 验证命令模板
|
||||
## 验证命令与本次结果
|
||||
|
||||
以下命令是实施后的计划,不是本次已执行结果。运行前核对解释器和实际新增测试名;在 app 根执行,主代理选择本波次测试文件。现有 `conftest.py` 使用旧 outputs 临时目录,T0 须将本任务测试夹具定向 scratch,避免测试运行污染真实偏好或输出。
|
||||
以下命令已在 app 根执行;未运行真实设备。
|
||||
|
||||
```powershell
|
||||
$pythonExe = 'D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe'
|
||||
& $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/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 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 -m pytest apps/autocal/tests -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-full
|
||||
& $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 tools/lint_gate.py
|
||||
& $pythonExe apps/autocal/tools/panel_check.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
|
||||
@@ -28,6 +28,16 @@ from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
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
|
||||
|
||||
SENSOR_MODEL = "finger"
|
||||
@@ -245,27 +255,29 @@ def stable_component_rows(
|
||||
return []
|
||||
with reference_path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
holds = list(csv.DictReader(handle))
|
||||
stamps: list[int | None] = []
|
||||
frames_path = repeat_dir / "capture" / "frames.csv"
|
||||
if frames_path.is_file():
|
||||
with frames_path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
# A gap is a hole in the force file, not a sample. A camera that
|
||||
# is plugged back in starts its frame numbers again, so the time
|
||||
# of a sample is the frame written with it, not the frame number.
|
||||
if row.get("dropped") not in (None, "", "0"):
|
||||
continue
|
||||
text = str(row.get("t_host_mono_ns") or "").strip()
|
||||
stamps.append(int(text) if text else None)
|
||||
# Frames and forces meet by frame_id, never by line number: a dropped row or
|
||||
# a camera that restarted would otherwise move a sample to another hold.
|
||||
# A repeated frame_id has no single time, so its samples are left out rather
|
||||
# than paired with an order this file cannot prove.
|
||||
capture_dir = repeat_dir / "capture"
|
||||
frame_rows = read_frame_index(capture_dir)
|
||||
unique, _duplicated = unique_frames(frame_rows)
|
||||
stamps: dict[int, int] = {
|
||||
frame_id: row.t_host_mono_ns
|
||||
for frame_id, row in unique.items()
|
||||
if not row.dropped and row.t_host_mono_ns is not None
|
||||
}
|
||||
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():
|
||||
with forces_path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
for index, row in enumerate(csv.DictReader(handle)):
|
||||
if index >= len(stamps):
|
||||
break
|
||||
for row in csv.DictReader(handle):
|
||||
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:
|
||||
continue
|
||||
samples.append((stamp, components[0], components[1]))
|
||||
@@ -422,11 +434,33 @@ class Campaign:
|
||||
self._pending: dict | None = None
|
||||
self._single_site = 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
|
||||
def is_active(self) -> bool:
|
||||
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(
|
||||
self,
|
||||
site_id: str,
|
||||
@@ -581,7 +615,7 @@ class Campaign:
|
||||
simulate: bool,
|
||||
min_force_n: float = 0.0,
|
||||
) -> 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")
|
||||
if sensor_model.strip() != SENSOR_MODEL:
|
||||
raise ValueError("multi-point acquisition uses the finger product model")
|
||||
@@ -610,6 +644,30 @@ class Campaign:
|
||||
self._finished_step_pending = False
|
||||
self._completed_holds = 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._document = {
|
||||
"format_version": "autocal-multipoint-1.0",
|
||||
@@ -726,25 +784,7 @@ class Campaign:
|
||||
self.phase = "load"
|
||||
return
|
||||
self._finished_step_pending = False
|
||||
self._close_repeat(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"
|
||||
self._begin_close(complete=True)
|
||||
|
||||
def confirm_liftoff(self) -> None:
|
||||
if self.phase != "liftoff":
|
||||
@@ -766,6 +806,8 @@ class Campaign:
|
||||
self.phase = "load"
|
||||
|
||||
def stop(self, reason: str) -> None:
|
||||
if self.phase == "closing" or self.capture_state == "closing":
|
||||
return
|
||||
if not self.is_active:
|
||||
return
|
||||
self.reason = reason or "stopped"
|
||||
@@ -778,8 +820,10 @@ class Campaign:
|
||||
self._reference_file.flush()
|
||||
self._active = None
|
||||
self._pending = None
|
||||
if self._trial_dir is not None:
|
||||
self._close_repeat(complete=False)
|
||||
self._hold_started_ns = None
|
||||
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.phase = "aborted"
|
||||
|
||||
@@ -913,10 +957,11 @@ class Campaign:
|
||||
"total": self._total_holds(),
|
||||
"directory": str(self.directory) if self.directory else "",
|
||||
"reason": self.reason,
|
||||
"capture": self.capture_summary(),
|
||||
}
|
||||
|
||||
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:
|
||||
raise RuntimeError("no trial is open")
|
||||
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")
|
||||
self._pending_worker = worker
|
||||
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:
|
||||
hold = _finite(hold_s, "hold time")
|
||||
@@ -978,6 +1023,13 @@ class Campaign:
|
||||
)
|
||||
|
||||
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]
|
||||
self._steps = self._steps_for(site)
|
||||
self._step_index = 0
|
||||
@@ -987,8 +1039,10 @@ class Campaign:
|
||||
folder = _path_part(site.site_id)
|
||||
trial_id = f"{folder}-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)
|
||||
self._trial_dir = directory
|
||||
metadata = {
|
||||
"trial_id": trial_id,
|
||||
"site_id": site.site_id,
|
||||
@@ -1013,9 +1067,30 @@ class Campaign:
|
||||
"captured": False,
|
||||
"reason": "simulation does not open a camera" if self.simulate else "camera not attached yet",
|
||||
},
|
||||
"capture_status": "idle",
|
||||
"capture_reason": "",
|
||||
"status": "incomplete",
|
||||
"reason": "repeat has not finished",
|
||||
}
|
||||
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")
|
||||
writer = csv.writer(reference_file)
|
||||
writer.writerow(REFERENCE_HEADER)
|
||||
@@ -1028,38 +1103,253 @@ class Campaign:
|
||||
events.flush()
|
||||
self._events_file = events
|
||||
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._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:
|
||||
self._release_images()
|
||||
def _write_repeat_manifest(
|
||||
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:
|
||||
try:
|
||||
self._reference_file.close()
|
||||
finally:
|
||||
self._reference_file = None
|
||||
if self._events_file is not None:
|
||||
try:
|
||||
self._events_file.close()
|
||||
finally:
|
||||
self._events_file = 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_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()
|
||||
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:
|
||||
self._document["status"] = status
|
||||
@@ -1163,12 +1453,18 @@ class Campaign:
|
||||
self._events.writerow(row)
|
||||
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:
|
||||
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
|
||||
|
||||
self._release_images()
|
||||
recorder = ExperimentRecorder(
|
||||
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},
|
||||
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._image_worker = self._pending_worker
|
||||
trial = self._document["trials"][-1]
|
||||
trial["images"] = {"captured": True, "directory": f"{trial['directory']}/capture"}
|
||||
self._guard = guard
|
||||
self._image_worker = worker
|
||||
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()
|
||||
|
||||
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._guard = None
|
||||
self._image_worker = None
|
||||
if worker is not None and recorder is not None:
|
||||
worker.remove_sink(recorder.on_packet)
|
||||
if worker is not None and guard is not None:
|
||||
worker.remove_sink(guard)
|
||||
if recorder is not None:
|
||||
recorder.stop(wait=True)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from uuid import uuid4
|
||||
import numpy as np
|
||||
|
||||
from .calibration_session import write_json
|
||||
from .frames_index import read_frame_index, unique_frames
|
||||
|
||||
|
||||
def _rows(path: Path) -> list[dict]:
|
||||
@@ -158,25 +159,27 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
|
||||
bases: set[str] = set()
|
||||
in_video = 0
|
||||
segments: set[int] = set()
|
||||
for row in _rows(directory / "frames.csv"):
|
||||
if row.get("dropped") != "0" or not row.get("t_host_mono_ns"):
|
||||
frame_rows = read_frame_index(directory)
|
||||
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
|
||||
if row.get("in_video") != "1":
|
||||
if frame.frame_id in duplicated:
|
||||
continue
|
||||
segment = int(row["segment"])
|
||||
segment = frame.segment
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
|
||||
source["reason"] = "missing_video_segment"
|
||||
return source
|
||||
frame_id = int(row["frame_id"])
|
||||
if frame_id in frames:
|
||||
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":
|
||||
frames[frame.frame_id] = frame.t_host_mono_ns
|
||||
bases.add(frame.time_basis)
|
||||
in_video += 1
|
||||
if row.get("segment"):
|
||||
segments.add(int(row["segment"]))
|
||||
if segment:
|
||||
segments.add(segment)
|
||||
if not frames:
|
||||
source["reason"] = "no_recorded_video_frames"
|
||||
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",
|
||||
"detail": str(trial.get("reason") or record.get("status") or "")})
|
||||
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"]
|
||||
unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS]
|
||||
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"]):
|
||||
raise ValueError(f"trial identity or completion mismatch: {trial_id}")
|
||||
frames = {}
|
||||
for row in _rows(path / "frames.csv"):
|
||||
if row["dropped"] != "0" or not row["t_host_mono_ns"]:
|
||||
unique, duplicated = unique_frames(read_frame_index(path))
|
||||
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
|
||||
frame_id = int(row["frame_id"])
|
||||
if frame_id in frames:
|
||||
raise ValueError(f"duplicate frame identity in {trial_id}")
|
||||
frames[frame_id] = int(row["t_host_mono_ns"])
|
||||
frames[frame_id] = frame.t_host_mono_ns
|
||||
samples = []
|
||||
for row in _rows(path / "forces.csv"):
|
||||
if row["valid"] != "1" or not row["FN_raw"]:
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
|
||||
from .calibration_session import CalibrationSession, write_json
|
||||
from .capture_guard import CaptureGuard, capture_verdict, PENDING
|
||||
from .sensor.uv_flat import Site
|
||||
|
||||
|
||||
@@ -59,6 +60,10 @@ class ManualTrial:
|
||||
self.completed_steps: list[dict] = []
|
||||
self.reason = ""
|
||||
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.steps: list[tuple[str, float]] = []
|
||||
self.step_index = 0
|
||||
@@ -66,6 +71,7 @@ class ManualTrial:
|
||||
self.aborted = False
|
||||
self.reference_alignment_possible = False
|
||||
self._last_confirm_ns: int | None = None
|
||||
self._closing_since_ns: int | None = None
|
||||
self._reference_file = None
|
||||
self._reference_writer = None
|
||||
self._active_reference: tuple[int, str, float, float, int] | None = None
|
||||
@@ -192,8 +198,14 @@ class ManualTrial:
|
||||
self.step_index = 0
|
||||
self.aborted = False
|
||||
self._last_confirm_ns = None
|
||||
self._closing_since_ns = None
|
||||
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]})
|
||||
return directory
|
||||
|
||||
@@ -346,13 +358,20 @@ class ManualTrial:
|
||||
)
|
||||
self.reason = reason
|
||||
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.recorder.stop(wait=False)
|
||||
self._closing_since_ns = time.perf_counter_ns()
|
||||
self.status = "closing"
|
||||
self.capture_state = "closing"
|
||||
|
||||
def poll(self) -> str:
|
||||
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")
|
||||
elif self.holding and self.remaining_s <= 0:
|
||||
deadline = self._last_confirm_ns + round(self.hold_s * 1e9)
|
||||
@@ -360,32 +379,43 @@ class ManualTrial:
|
||||
if self._reference_error:
|
||||
self.finish(aborted=True, reason=self._reference_error)
|
||||
if self.status == "closing" and self.recorder is not None:
|
||||
report = self.recorder.report
|
||||
if report is not None:
|
||||
self.status = (
|
||||
"complete"
|
||||
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
|
||||
complete = not self.aborted and self.next_step is None
|
||||
elapsed = (
|
||||
None if self._closing_since_ns is None
|
||||
else (time.perf_counter_ns() - self._closing_since_ns) / 1e9
|
||||
)
|
||||
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({
|
||||
"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),
|
||||
"capture_status": verdict.status,
|
||||
"capture_reason": verdict.reason,
|
||||
"capture_report": verdict.report,
|
||||
})
|
||||
try:
|
||||
trial_path = self.directory / "session.json"
|
||||
document = json.loads(trial_path.read_text(encoding="utf-8"))
|
||||
document.update({"trial_status": self.status, "steps": self.completed_steps,
|
||||
"trial_reason": self.entry["reason"]})
|
||||
document.update({
|
||||
"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)
|
||||
self.session.save()
|
||||
except (OSError, ValueError):
|
||||
@@ -393,3 +423,18 @@ class ManualTrial:
|
||||
self.entry["status"] = "incomplete"
|
||||
raise
|
||||
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_outline import outline_snapshot
|
||||
from ..summary import build_summary
|
||||
from ..summary_export import publish_summary
|
||||
from ..trial import ManualTrial, load_path
|
||||
from .live_force_view import LiveForceView
|
||||
from .multipoint_panel import MultipointPanel
|
||||
from .review_job import ReviewJob
|
||||
from .sensor_panel import FlatSurfaceView
|
||||
|
||||
#: 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:
|
||||
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.trial = ManualTrial(source_panel.worker)
|
||||
self._flat = None
|
||||
@@ -305,6 +311,10 @@ class CalibrationPanel(QWidget):
|
||||
|
||||
def _confirm(self) -> None:
|
||||
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()
|
||||
return
|
||||
try:
|
||||
@@ -366,8 +376,46 @@ class CalibrationPanel(QWidget):
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
self.result_label.setText(t("calibration.error").format(detail=exc))
|
||||
else:
|
||||
self.publish_summary_for(directory)
|
||||
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:
|
||||
session = self.trial.session
|
||||
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
|
||||
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(
|
||||
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.abort_button.setEnabled(recording)
|
||||
@@ -433,7 +483,11 @@ class CalibrationPanel(QWidget):
|
||||
else:
|
||||
target_text = t("calibration.no_step")
|
||||
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.progress_label.setText(
|
||||
t("calibration.progress").format(
|
||||
|
||||
@@ -145,8 +145,11 @@ class MultipointPanel(QWidget):
|
||||
|
||||
self.progress = _WrappingLabel(page)
|
||||
self.save_status = _WrappingLabel(page)
|
||||
self.capture_status = _WrappingLabel(page)
|
||||
self.capture_status.setObjectName("multipointCapture")
|
||||
layout.addWidget(self.progress)
|
||||
layout.addWidget(self.save_status)
|
||||
layout.addWidget(self.capture_status)
|
||||
self.pose_status = _WrappingLabel(page)
|
||||
layout.addWidget(self.pose_status)
|
||||
|
||||
@@ -222,6 +225,7 @@ class MultipointPanel(QWidget):
|
||||
self._timer.timeout.connect(self._poll)
|
||||
self._timer.start()
|
||||
self._listed: tuple = ()
|
||||
self._summary_published_for = None
|
||||
self.retranslate()
|
||||
self.site_id.setText(self._next_site_id())
|
||||
self.frame.setText("sensor")
|
||||
@@ -239,6 +243,9 @@ class MultipointPanel(QWidget):
|
||||
self._timer.stop()
|
||||
if self.campaign.is_active:
|
||||
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:
|
||||
self.hint.setText(t("multipoint.hint"))
|
||||
@@ -471,6 +478,7 @@ class MultipointPanel(QWidget):
|
||||
def go():
|
||||
if self.host.trial.status in {"recording", "closing"}:
|
||||
raise RuntimeError(t("multipoint.finger_busy"))
|
||||
self._summary_published_for = None
|
||||
if not simulate:
|
||||
worker = self.host.source_panel.worker
|
||||
packet = worker.latest()
|
||||
@@ -487,10 +495,17 @@ class MultipointPanel(QWidget):
|
||||
simulate=simulate,
|
||||
)
|
||||
if not simulate:
|
||||
try:
|
||||
self.campaign.attach_capture(
|
||||
self.host.source_panel.worker,
|
||||
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._run(go)
|
||||
|
||||
@@ -537,6 +552,7 @@ class MultipointPanel(QWidget):
|
||||
|
||||
def _reacquire(self) -> None:
|
||||
if self._selected:
|
||||
self._summary_published_for = None
|
||||
self._run(lambda: self.campaign.reacquire(self._selected))
|
||||
|
||||
def _liftoff(self) -> None:
|
||||
@@ -553,6 +569,12 @@ class MultipointPanel(QWidget):
|
||||
self._refresh()
|
||||
|
||||
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:
|
||||
try:
|
||||
self.campaign.complete_hold()
|
||||
@@ -560,8 +582,21 @@ class MultipointPanel(QWidget):
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
if self.campaign.is_active:
|
||||
self.campaign.stop(str(exc))
|
||||
self._publish_summary_when_finished()
|
||||
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:
|
||||
try:
|
||||
action()
|
||||
@@ -604,6 +639,7 @@ class MultipointPanel(QWidget):
|
||||
for column, value in enumerate(values):
|
||||
self.table.setItem(row, column, QTableWidgetItem(value))
|
||||
active = self.campaign.is_active
|
||||
busy = self.campaign.busy
|
||||
wrench = self.source.currentData() == "wrench"
|
||||
self.wrench_box.setVisible(wrench)
|
||||
for widget in (
|
||||
@@ -611,14 +647,14 @@ class MultipointPanel(QWidget):
|
||||
self.down_button, self.simulate_button, self.start_button,
|
||||
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):
|
||||
widget.setEnabled(self.campaign.phase == "load" and wrench)
|
||||
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
|
||||
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
|
||||
self.stop_button.setEnabled(active)
|
||||
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()
|
||||
next_id = state["site_id"] if self.campaign.phase == "liftoff" else ""
|
||||
@@ -669,8 +705,42 @@ class MultipointPanel(QWidget):
|
||||
save = t("multipoint.save_done")
|
||||
elif self.campaign.phase == "aborted":
|
||||
save = t("multipoint.save_kept")
|
||||
elif active:
|
||||
elif active or self.campaign.closing:
|
||||
save = t("multipoint.save_writing")
|
||||
else:
|
||||
save = t("multipoint.save_idle")
|
||||
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._selected_site_id = None
|
||||
self.plan = None
|
||||
self._review_path = None
|
||||
self.rows = []
|
||||
self.setWindowTitle(t("overview.title"))
|
||||
self.resize(1240, 850)
|
||||
@@ -161,8 +162,10 @@ class RecordOverviewDialog(QDialog):
|
||||
self.tools.setText(t("overview.tools"))
|
||||
self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
menu = QMenu(self.tools)
|
||||
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), ("load", self._load)):
|
||||
action = menu.addAction(t("overview." + name))
|
||||
for name, slot in (("video", self._video), ("summary", self._publish_summary),
|
||||
("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.triggered.connect(slot)
|
||||
self.tools.setMenu(menu)
|
||||
@@ -546,9 +549,68 @@ class RecordOverviewDialog(QDialog):
|
||||
self._dirty = self._dirty or before != self.review["selections"]
|
||||
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):
|
||||
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)
|
||||
def _job_finished(self, value, error):
|
||||
@@ -560,11 +622,17 @@ class RecordOverviewDialog(QDialog):
|
||||
elif self._job_kind == "save":
|
||||
self._dirty = False
|
||||
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.setToolTip(str(value))
|
||||
else:
|
||||
self.review, self.plan = value
|
||||
self.review, self.plan, self._review_path = value
|
||||
self._dirty = False
|
||||
self._refresh()
|
||||
|
||||
@@ -582,4 +650,10 @@ class RecordOverviewDialog(QDialog):
|
||||
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
|
||||
if path:
|
||||
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()
|
||||
except Exception as exc:
|
||||
# Worker failures must return to Qt so controls are always restored.
|
||||
self.result.ready.emit(None, str(exc))
|
||||
self._emit(None, str(exc))
|
||||
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")
|
||||
self.verified = QCheckBox(t("review.confirm_stable"), 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(self.start_s)
|
||||
editor.addWidget(QLabel(t("review.end_offset"), self))
|
||||
editor.addWidget(self.end_s)
|
||||
editor.addWidget(self.verified)
|
||||
editor.addWidget(self.apply_button)
|
||||
editor.addWidget(self.video_button)
|
||||
hold_layout.addLayout(editor)
|
||||
self.trace_chart = QChart()
|
||||
self.trace_view = QChartView(self.trace_chart, self)
|
||||
@@ -185,6 +189,7 @@ class SessionReviewDialog(QDialog):
|
||||
window = self._current_window()
|
||||
clear_chart(self.trace_chart)
|
||||
self.apply_button.setEnabled(window is not None)
|
||||
self.video_button.setEnabled(window is not None)
|
||||
if window is None:
|
||||
return
|
||||
origin = window["hold_start_host_mono_ns"]
|
||||
@@ -217,6 +222,30 @@ class SessionReviewDialog(QDialog):
|
||||
self.trace_chart.legend().hide()
|
||||
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):
|
||||
window = self._current_window()
|
||||
if window is None:
|
||||
@@ -279,7 +308,16 @@ class SessionReviewDialog(QDialog):
|
||||
|
||||
def _save(self) -> None:
|
||||
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):
|
||||
if self._job is not None:
|
||||
@@ -302,4 +340,6 @@ class SessionReviewDialog(QDialog):
|
||||
self.review = value
|
||||
self._show_site()
|
||||
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)
|
||||
@@ -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",
|
||||
"AspectRatioMode",
|
||||
"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"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path):
|
||||
"""A camera that starts again reuses frame numbers. Each hold keeps its own rows."""
|
||||
def test_reused_frame_numbers_are_left_out_of_the_means(tmp_path):
|
||||
"""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
|
||||
|
||||
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)
|
||||
|
||||
assert float(rows[0]["FN_mean"]) == pytest.approx(20.0)
|
||||
assert rows[0]["branch"] == "load"
|
||||
assert float(rows[1]["FN_mean"]) == pytest.approx(5.0)
|
||||
assert rows[1]["branch"] == "unload"
|
||||
assert rows[0]["branch"] == "load" and rows[1]["branch"] == "unload"
|
||||
assert all(row["samples"] == 0 and row["unit"] == "" for row in rows)
|
||||
assert all(row["FN_mean"] == row["FX_mean"] == row["FY_mean"] == "" for row in rows)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 "<script>" 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user