From 19426c3c4a648ca7f4d3785e2e11b1bf124a8e31 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sat, 17 Oct 2026 10:44:30 +0800 Subject: [PATCH] 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. --- apps/autocal/config/locales/en_US.json | 52 +- apps/autocal/config/locales/zh_CN.json | 52 +- .../docs/calibration-data-format.zh.md | 28 +- .../autocal/docs/multipoint-data-format.zh.md | 35 +- .../docs/recording-summary-contract.zh.md | 4 +- .../autocal/docs/recording-summary-plan.zh.md | 2 +- .../docs/recording-summary-tasks.zh.md | 38 +- .../src/orisys_autocal/capture_guard.py | 225 ++++++ .../src/orisys_autocal/frames_index.py | 327 +++++++++ apps/autocal/src/orisys_autocal/multipoint.py | 465 ++++++++++--- .../src/orisys_autocal/session_review.py | 49 +- apps/autocal/src/orisys_autocal/summary.py | 655 ++++++++++++++++++ .../src/orisys_autocal/summary_export.py | 390 +++++++++++ apps/autocal/src/orisys_autocal/trial.py | 113 ++- .../orisys_autocal/ui/calibration_panel.py | 60 +- .../src/orisys_autocal/ui/multipoint_panel.py | 84 ++- .../ui/record_overview_dialog.py | 88 ++- .../src/orisys_autocal/ui/review_job.py | 14 +- .../ui/session_review_dialog.py | 44 +- .../orisys_autocal/ui/video_review_dialog.py | 359 ++++++++++ .../src/orisys_autocal/video_review.py | 263 +++++++ apps/autocal/tests/evidence_helpers.py | 563 +++++++++++++++ apps/autocal/tests/test_binding_purity.py | 3 + apps/autocal/tests/test_frames_index.py | 170 +++++ apps/autocal/tests/test_multipoint.py | 56 +- .../tests/test_multipoint_recording.py | 362 ++++++++++ apps/autocal/tests/test_summary.py | 265 +++++++ apps/autocal/tests/test_summary_export.py | 175 +++++ apps/autocal/tests/test_summary_fit.py | 166 +++++ apps/autocal/tests/test_summary_lifecycle.py | 150 ++++ apps/autocal/tests/test_video_review.py | 246 +++++++ .../autocal/tests/test_video_review_dialog.py | 149 ++++ 32 files changed, 5461 insertions(+), 191 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/capture_guard.py create mode 100644 apps/autocal/src/orisys_autocal/frames_index.py create mode 100644 apps/autocal/src/orisys_autocal/summary.py create mode 100644 apps/autocal/src/orisys_autocal/summary_export.py create mode 100644 apps/autocal/src/orisys_autocal/ui/video_review_dialog.py create mode 100644 apps/autocal/src/orisys_autocal/video_review.py create mode 100644 apps/autocal/tests/evidence_helpers.py create mode 100644 apps/autocal/tests/test_frames_index.py create mode 100644 apps/autocal/tests/test_multipoint_recording.py create mode 100644 apps/autocal/tests/test_summary.py create mode 100644 apps/autocal/tests/test_summary_export.py create mode 100644 apps/autocal/tests/test_summary_fit.py create mode 100644 apps/autocal/tests/test_summary_lifecycle.py create mode 100644 apps/autocal/tests/test_video_review.py create mode 100644 apps/autocal/tests/test_video_review_dialog.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 7b8d79c..ce8b4da 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -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" } } diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 367afae..0ebe649 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -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": "这个保持尝试已被重测废弃,没有可复核的画面" } } diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 5ef22fa..b519317 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -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_.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)。 diff --git a/apps/autocal/docs/multipoint-data-format.zh.md b/apps/autocal/docs/multipoint-data-format.zh.md index 49cdedd..b804528 100644 --- a/apps/autocal/docs/multipoint-data-format.zh.md +++ b/apps/autocal/docs/multipoint-data-format.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//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//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//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`;因此重复帧号的样本不再参与旧表均值,其余数值规则不变。 diff --git a/apps/autocal/docs/recording-summary-contract.zh.md b/apps/autocal/docs/recording-summary-contract.zh.md index d6c6ce1..2784903 100644 --- a/apps/autocal/docs/recording-summary-contract.zh.md +++ b/apps/autocal/docs/recording-summary-contract.zh.md @@ -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 均不覆盖。 ## 录像身份、时间和生命周期 diff --git a/apps/autocal/docs/recording-summary-plan.zh.md b/apps/autocal/docs/recording-summary-plan.zh.md index 098e6d5..94f1681 100644 --- a/apps/autocal/docs/recording-summary-plan.zh.md +++ b/apps/autocal/docs/recording-summary-plan.zh.md @@ -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),离线测试全绿不代表硬件验收。 ## 现状与问题 diff --git a/apps/autocal/docs/recording-summary-tasks.zh.md b/apps/autocal/docs/recording-summary-tasks.zh.md index 73c7419..330c427 100644 --- a/apps/autocal/docs/recording-summary-tasks.zh.md +++ b/apps/autocal/docs/recording-summary-tasks.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 测试替代消费者回归。 diff --git a/apps/autocal/src/orisys_autocal/capture_guard.py b/apps/autocal/src/orisys_autocal/capture_guard.py new file mode 100644 index 0000000..3ecdb90 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/capture_guard.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/frames_index.py b/apps/autocal/src/orisys_autocal/frames_index.py new file mode 100644 index 0000000..93957fc --- /dev/null +++ b/apps/autocal/src/orisys_autocal/frames_index.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/multipoint.py b/apps/autocal/src/orisys_autocal/multipoint.py index 0f70d76..c0bfc92 100644 --- a/apps/autocal/src/orisys_autocal/multipoint.py +++ b/apps/autocal/src/orisys_autocal/multipoint.py @@ -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,10 +820,12 @@ 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._finish_session("incomplete") - self.phase = "aborted" + 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" def can_remeasure_step(self) -> bool: """True while a hold can be dropped, or the step that just finished can be taken back.""" @@ -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}) + 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.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._start_images(self._pending_worker, entry) - def _close_repeat(self, *, complete: bool) -> None: - self._release_images() - if self._reference_file is not None: - self._reference_file.close() - self._reference_file = None - if self._events_file is not None: - self._events_file.close() - 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 + 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) - self._document["trials"][-1]["status"] = metadata["status"] - self._document["trials"][-1]["reason"] = metadata["reason"] + + 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 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) diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index 2b66caf..befa5c2 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -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": - in_video += 1 - if row.get("segment"): - segments.add(int(row["segment"])) + frames[frame.frame_id] = frame.t_host_mono_ns + bases.add(frame.time_basis) + in_video += 1 + 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"]: diff --git a/apps/autocal/src/orisys_autocal/summary.py b/apps/autocal/src/orisys_autocal/summary.py new file mode 100644 index 0000000..e282a78 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/summary.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/summary_export.py b/apps/autocal/src/orisys_autocal/summary_export.py new file mode 100644 index 0000000..55d866c --- /dev/null +++ b/apps/autocal/src/orisys_autocal/summary_export.py @@ -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( + "%s%s" + % (html.escape(site_id), html.escape("not available")) + ) + continue + validation = quality.get("validation") or {} + rows.append( + "%s%s%s%s%s" % ( + 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 ( + "

Repeatability, hysteresis and held-out validation (existing review)

" + "" + "%s
PointRepeat groupsHysteresis rowsHeld-out windowsHeld-out RMSE (N)
" + % "".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("%s" % "".join(f"{cell}" for cell in cells)) + return ( + "

Sensitivity per point, trial and component (raw per N)

" + "

Method %s: 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.

" + "" + "" + "%s
PointComponentReview setSlopeInterceptR2EndpointInitial stepWindowsStatus
" + % (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("%s" % "".join(f"{cell}" for cell in cells)) + return ( + "

Every hold attempt (raw components)

" + "

An attempt is listed even when it is excluded, so nothing disappears quietly. " + "A repeated attempt is excluded with remeasured.

" + "" + "" + "" + "%s
PointTrialHoldBranchReference NFNFXFYSamplesWindowEligibleExcluded reason
" % "".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( + "
  • %s: %s — %s
  • " + % ( + html.escape(trial_id), + html.escape(str(trial.get("capture_status") or "")), + html.escape(directory), + ) + ) + else: + links.append( + "
  • %s: %s
  • " + % (html.escape(trial_id), html.escape(str(trial.get("capture_status") or ""))) + ) + return "

    Evidence

      %s
    " % "".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 = [ + "", + "Autocal summary %s" % html.escape(str(summary.get("summary_id") or "")), + "", + "

    Autocal recording summary

    ", + "

    Model %s · SN %s · Session %s

    " % ( + html.escape(str(summary.get("sensor_model") or "—")), + html.escape(str(summary.get("sensor_sn") or "—")), + html.escape(str(summary.get("session_id") or "—")), + ), + "

    Points complete %d / %d · Capture incomplete %d · " + "Windows awaiting review %d · Missing data %s

    " % ( + done, len(completion), len(incomplete_capture), pending, html.escape(missing), + ), + "

    This page states measurements and their gaps. It is not a " + "pass/fail certificate, and it does not validate physical accuracy.

    ", + _sensitivity_table(summary), + _window_table(summary), + _quality_section(summary), + _evidence_links(summary), + "

    Schema %s · generated %s

    " % ( + html.escape(str(summary.get("schema_version") or SCHEMA_VERSION)), + html.escape(str(summary.get("generated_at") or "")), + ), + "", + ] + 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 diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py index 5caa64e..86506a0 100644 --- a/apps/autocal/src/orisys_autocal/trial.py +++ b/apps/autocal/src/orisys_autocal/trial.py @@ -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 - self.worker.remove_sink(self.recorder.on_packet) + 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,36 +379,62 @@ 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 - ) - else "incomplete" - ) - self.entry.update({ - "status": self.status, - "reason": self.reason or ("" if self.status == "complete" else "recording integrity check failed"), - "steps": list(self.completed_steps), + 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 + ) + 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 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"], + "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"]}) - write_json(trial_path, document) - self.session.save() - except (OSError, ValueError): - self.status = "incomplete" - self.entry["status"] = "incomplete" - raise + write_json(trial_path, document) + self.session.save() + except (OSError, ValueError): + self.status = "incomplete" + 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 ""), + } diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 9f5a6b3..15d9efb 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -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( diff --git a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py b/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py index 8572829..60bc68e 100644 --- a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py @@ -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: - self.campaign.attach_capture( - self.host.source_panel.worker, - fps=float(self.host.source_panel.mode.fps), - ) + 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 "—"), + ) diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py index ee4775a..23c9b74 100644 --- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/ui/review_job.py b/apps/autocal/src/orisys_autocal/ui/review_job.py index 47d3edf..ecd85ca 100644 --- a/apps/autocal/src/orisys_autocal/ui/review_job.py +++ b/apps/autocal/src/orisys_autocal/ui/review_job.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py index 8162e69..6992bac 100644 --- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -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)) diff --git a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py new file mode 100644 index 0000000..fbf2547 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/video_review.py b/apps/autocal/src/orisys_autocal/video_review.py new file mode 100644 index 0000000..4391a74 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/video_review.py @@ -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) diff --git a/apps/autocal/tests/evidence_helpers.py b/apps/autocal/tests/evidence_helpers.py new file mode 100644 index 0000000..1d32734 --- /dev/null +++ b/apps/autocal/tests/evidence_helpers.py @@ -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 diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py index f305114..80ccccd 100644 --- a/apps/autocal/tests/test_binding_purity.py +++ b/apps/autocal/tests/test_binding_purity.py @@ -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",), diff --git a/apps/autocal/tests/test_frames_index.py b/apps/autocal/tests/test_frames_index.py new file mode 100644 index 0000000..c2f9e71 --- /dev/null +++ b/apps/autocal/tests/test_frames_index.py @@ -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 diff --git a/apps/autocal/tests/test_multipoint.py b/apps/autocal/tests/test_multipoint.py index 6c8c2aa..e2b8c86 100644 --- a/apps/autocal/tests/test_multipoint.py +++ b/apps/autocal/tests/test_multipoint.py @@ -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: diff --git a/apps/autocal/tests/test_multipoint_recording.py b/apps/autocal/tests/test_multipoint_recording.py new file mode 100644 index 0000000..7b1c05d --- /dev/null +++ b/apps/autocal/tests/test_multipoint_recording.py @@ -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() diff --git a/apps/autocal/tests/test_summary.py b/apps/autocal/tests/test_summary.py new file mode 100644 index 0000000..ac692a1 --- /dev/null +++ b/apps/autocal/tests/test_summary.py @@ -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 diff --git a/apps/autocal/tests/test_summary_export.py b/apps/autocal/tests/test_summary_export.py new file mode 100644 index 0000000..c1b5fa2 --- /dev/null +++ b/apps/autocal/tests/test_summary_export.py @@ -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"] = "" + + document = render_html(summary) + + assert "