diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json
index cb4827e..dc2db74 100644
--- a/apps/autocal/config/locales/en_US.json
+++ b/apps/autocal/config/locales/en_US.json
@@ -488,6 +488,8 @@
"flow_missing": "No valid displacement for this frame"
},
"overview": {
+ "capture_in_progress": "In progress",
+ "saved": "Analysis saved; hover for its location.",
"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",
@@ -496,36 +498,64 @@
"sensitivity": "Normal sensitivity",
"import_plan": "Import planned-point CSV…",
"clear_plan": "Clear plan comparison",
- "load": "Load saved review…",
- "save": "Save new overview and review",
- "summary": "Acquired {collected}; planned {planned}; missing {missing}; incomplete attempts {incomplete}; pending review {pending}; reviewed {reviewed}; outside plan {extra}.",
- "no_plan": "Without a plan, missing sites cannot be determined. Plan order is separate from acquisition site_id.",
+ "load": "Load saved analysis…",
+ "save": "Save analysis results",
+ "summary": "Collected {collected} / planned {planned}; missing {missing}; with incomplete attempts {incomplete}; unchecked {pending}; checked {reviewed}; extra {extra}.",
+ "no_plan": "Import a plan to identify missing sites.",
"uncollected": "Not acquired",
"incomplete": "Has incomplete attempts",
- "pending": "Pending review",
- "reviewed": "Reviewed",
- "confirmed": "Confirmed",
- "candidate": "Candidate",
- "coverage_map": "Red: incomplete attempts; orange: pending; green: reviewed; grey: not acquired",
+ "pending": "Awaiting check",
+ "reviewed": "Stable windows checked",
+ "confirmed": "Checked",
+ "candidate": "Unchecked",
+ "coverage_map": "Red: incomplete attempts; amber: unchecked; green: checked; grey: uncollected",
"sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N
Grey: no confirmed fit",
- "details": "Inspect signal / review windows…",
+ "details": "Check hold windows…",
"trial_filter": "Displayed trial",
"all_trials": "All trials",
"site_fit": "Site loading fit (all eligible trials)",
"response_title": "Normal response: reference force → raw signal",
- "selected_fit": "Site exploratory sensitivity {sensitivity} raw/N ({basis}); fit range and trial count are in the table. Physical signal/reference validation is still required.",
- "no_windows": "No complete hold windows for this site/trial. Incomplete trials are excluded from fits; raw files are retained.",
+ "selected_fit": "Sensitivity {sensitivity} raw/N · {basis}. Fit uses all eligible loading windows at this site; range and trial count are in the site tooltip.",
+ "no_windows": "No eligible complete windows; recorded signal and stop diagnostics remain available.",
"col_order": "Plan order",
"col_site": "Acquired site",
"col_uv": "U / V (px)",
"col_xyz": "Sensor XYZ (mm)",
- "col_status": "Status",
+ "col_status": "Analysis readiness",
"col_trials": "Complete / all trials",
"col_windows": "Confirmed / complete windows",
"col_levels": "Confirmed loading levels (N)",
"col_sensitivity": "Sensitivity (raw/N) / basis",
"col_range": "Fit range (N)",
- "col_fit_trials": "Fit trials"
+ "col_fit_trials": "Fit trials",
+ "tools": "Plan / saved analysis",
+ "identity": "Model: {model} SN: {sn} Acquired: {date} Source: {source} Capture: {status}",
+ "source_video": "Video replay (debug only)",
+ "source_camera": "Camera",
+ "timeline": "Time signal / diagnostics",
+ "response": "Loading / unloading response",
+ "time_axis": "Time from each trial’s first valid sample (s)",
+ "reference_axis": "Declared reference (N)",
+ "timeline_title": "Blue: FN_raw; orange: declared hold reference (not live FT)",
+ "no_trace": "No readable valid signal; inspect diagnostics below",
+ "outline_snapshot": "Grey: sensor boundary; blue: colour patch boundary (recorded snapshot)",
+ "outline_surface_only": "Sensor mapping verified; no recorded colour patch outline is available.",
+ "outline_unavailable": "Recorded UV outline unavailable; showing sites only.",
+ "outline_mismatch": "UV outline does not match the recorded mapping; overlay disabled.",
+ "analysis_reviewed": "Stable windows checked",
+ "analysis_pending": "Check stable windows",
+ "analysis_no_analysis": "No eligible windows",
+ "site_details": "XYZ (mm): {xyz}\nComplete / all attempts: {attempts}\nFit range (N): {range}; contributing trials: {trials}",
+ "inspect_incomplete": "Inspect incomplete attempt",
+ "diagnostic": "{trial} · {status} | Valid samples {samples}; invalid / unjoined {invalid}; recording drops {dropped}; largest frame gap {gap} s\nStop reason: {reason}",
+ "read_errors": "Missing or unreadable: {detail}",
+ "choose_trial": "Select one trial for integrity, drop and stop diagnostics. All-trial traces use separate time origins.",
+ "unsaved": "Window or plan changes have not been saved.",
+ "save_hint": "Confirming stability does not certify calibration accuracy.",
+ "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."
},
"quality": {
"open": "Repeatability and validation…",
@@ -562,9 +592,9 @@
"col_range_status": "Range/status"
},
"review": {
- "load_selection": "Load saved review…",
- "confirm_stable": "Reviewed as stable",
- "apply_window": "Apply window and recalculate",
+ "load_selection": "Load saved analysis…",
+ "confirm_stable": "This interval was checked for stability",
+ "apply_window": "Confirm interval and recompute",
"start_offset": "Start",
"end_offset": "End",
"verified": "Stability confirmed by operator",
@@ -575,9 +605,9 @@
"trace_title": "Selected hold signal; vertical lines mark the analysis window",
"selection_unsaved": "Window applied; save the analysis to preserve this review.",
"open": "Review calibration data…",
- "title": "Exploratory calibration review",
- "notice": "Inspect each raw hold signal, adjust the times relative to hold start, confirm stability and apply. Operator review does not prove reference accuracy or synchronisation. Replay is for debugging; fits are exploratory.",
- "save": "Save new review",
+ "title": "Hold window inspection",
+ "notice": "Select a hold → inspect signal, standard deviation and drift → adjust the interval → confirm stability and recompute → save. Operator confirmation is not accuracy acceptance.",
+ "save": "Save analysis results",
"no_sites": "No sites in this session",
"candidate": "Candidate window",
"insufficient_samples": "Insufficient valid samples",
@@ -590,7 +620,7 @@
"force_axis": "Manual reference force (N)",
"chart_title": "Hold-window means: loading vs unloading",
"uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit",
- "issues": "Skipped {count} incomplete trials; complete holds still require stability review.",
+ "issues": "{count} incomplete attempts excluded from fitting. Inspect raw signals for stability.",
"saved": "Saved new review: {path}",
"col_trial": "Trial",
"col_branch": "Phase",
@@ -599,6 +629,7 @@
"col_mean": "Raw mean",
"col_std": "Raw std",
"col_duration": "Hold (s)",
- "col_quality": "Quality"
+ "col_quality": "Quality",
+ "col_drift": "Drift (raw/s)"
}
}
diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json
index 88ab201..594edbf 100644
--- a/apps/autocal/config/locales/zh_CN.json
+++ b/apps/autocal/config/locales/zh_CN.json
@@ -488,6 +488,8 @@
"flow_missing": "本帧无有效位移信号"
},
"overview": {
+ "capture_in_progress": "采集中",
+ "saved": "分析结果已保存;保存位置见提示。",
"open_session": "打开标定会话…",
"empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。",
"title": "标定记录总览",
@@ -496,36 +498,64 @@
"sensitivity": "法向灵敏度",
"import_plan": "导入计划测点 CSV…",
"clear_plan": "清除计划对照",
- "load": "载入已保存复核…",
- "save": "另存总览与复核",
- "summary": "已采集 {collected} 点;计划 {planned} 点;未采 {missing};有未完成试验 {incomplete};待复核 {pending};已复核 {reviewed};计划外 {extra}。",
- "no_plan": "未导入计划表,无法判断漏采点;序号仅用于计划对照,不作为采集 site_id。",
+ "load": "载入历史分析…",
+ "save": "保存分析结果",
+ "summary": "已采 {collected} 点/计划 {planned} 点;漏采 {missing};有未完成试验 {incomplete};待检查 {pending};已检查 {reviewed};计划外 {extra}。",
+ "no_plan": "未导入计划,暂不能判断漏采点。",
"uncollected": "尚未采集",
"incomplete": "有未完成试验",
- "pending": "待复核",
- "reviewed": "已复核",
- "confirmed": "已确认",
- "candidate": "候选",
- "coverage_map": "红:有未完成试验;橙:待复核;绿:已复核;灰:未采",
+ "pending": "待检查",
+ "reviewed": "稳定段已检查",
+ "confirmed": "已检查",
+ "candidate": "待检查",
+ "coverage_map": "红:有未完成试验;橙:待检查;绿:已检查;灰:未采",
"sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N
灰:无已确认拟合",
- "details": "查看信号/复核窗口…",
+ "details": "检查保持段…",
"trial_filter": "显示试验",
"all_trials": "全部 trial",
"site_fit": "测点加载拟合(全部符合条件的 trial)",
"response_title": "法向响应:参考力 → 原始信号",
- "selected_fit": "本测点初步灵敏度 {sensitivity} raw/N({basis});拟合范围和 trial 数见表格。原始信号与实际参考力仍须实机验证。",
- "no_windows": "所选点/trial 无完整保持窗口;未完成试验不参与拟合,原始文件仍保留。",
+ "selected_fit": "灵敏度 {sensitivity} raw/N · {basis}。拟合按本点全部合格加载窗口计算;范围及试验数见测点提示。",
+ "no_windows": "所选试验没有可分析的完整窗口;可查看已有原始信号和结束原因。",
"col_order": "计划序号",
"col_site": "采集测点",
"col_uv": "U / V (px)",
"col_xyz": "传感器 XYZ (mm)",
- "col_status": "状态",
+ "col_status": "分析准备状态",
"col_trials": "完成/全部 trial",
"col_windows": "确认/完整窗口",
"col_levels": "已确认加载档位 (N)",
"col_sensitivity": "灵敏度 (raw/N)/依据",
"col_range": "拟合范围 (N)",
- "col_fit_trials": "拟合 trial 数"
+ "col_fit_trials": "拟合 trial 数",
+ "tools": "计划/历史分析",
+ "identity": "型号:{model} SN:{sn} 采集时间:{date} 来源:{source} 采集:{status}",
+ "source_video": "视频重放(仅调试)",
+ "source_camera": "相机",
+ "timeline": "时间曲线/记录诊断",
+ "response": "加载/卸载响应",
+ "time_axis": "距各试验首个有效样本 (s)",
+ "reference_axis": "人工参考力 (N)",
+ "timeline_title": "蓝:FN_raw;橙:人工保持参考力(不是实时 FT)",
+ "no_trace": "没有可读的有效信号;请查看下方记录诊断",
+ "outline_snapshot": "灰:传感面轮廓;蓝:彩色 patch 区域轮廓(采集快照)",
+ "outline_surface_only": "传感面映射已核对;未保存彩色 patch 轮廓,无法恢复。",
+ "outline_unavailable": "找不到记录对应的 UV 轮廓;仅显示测点。",
+ "outline_mismatch": "UV 轮廓与记录映射不一致,已停用叠加。",
+ "analysis_reviewed": "稳定段已检查",
+ "analysis_pending": "待检查稳定段",
+ "analysis_no_analysis": "无可分析窗口",
+ "site_details": "XYZ (mm):{xyz}\n完成/全部试验:{attempts}\n拟合范围 (N):{range};拟合试验数:{trials}",
+ "inspect_incomplete": "查看中止记录",
+ "diagnostic": "{trial} · {status}|有效样本 {samples};无效/未关联 {invalid};录像丢帧 {dropped};最大帧间隔 {gap} s\n结束原因:{reason}",
+ "read_errors": "缺失或无法读取:{detail}",
+ "choose_trial": "请选择单次试验查看完整性、丢帧及结束原因;全部试验曲线按各自起点对齐。",
+ "unsaved": "窗口或计划已修改,尚未保存。",
+ "save_hint": "确认稳定只表示检查过信号,不代表标定精度合格。",
+ "guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。",
+ "guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。",
+ "guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。",
+ "guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。"
},
"quality": {
"open": "重复性与验证…",
@@ -562,9 +592,9 @@
"col_range_status": "范围/状态"
},
"review": {
- "load_selection": "载入已保存复核…",
- "confirm_stable": "已检查此段稳定",
- "apply_window": "应用窗口并重算",
+ "load_selection": "载入历史分析…",
+ "confirm_stable": "此时间段已检查稳定",
+ "apply_window": "确认选中时间段并重算",
"start_offset": "起点",
"end_offset": "终点",
"verified": "人工已确认稳定",
@@ -575,9 +605,9 @@
"trace_title": "选中保持段原始信号;竖线为分析窗口边界",
"selection_unsaved": "窗口已应用;请另存分析结果以保留复核。",
"open": "查看标定数据…",
- "title": "标定数据初步分析",
- "notice": "逐段查看原始信号,调整起止时间并确认稳定后应用。起止时间相对本段保持开始;人工复核不证明参考力准确或同步。视频重放仅供调试;拟合不是正式标定系数。",
- "save": "另存分析结果",
+ "title": "保持段检查",
+ "notice": "选择保持段 → 检查曲线、标准差及漂移 → 调整起止时间 → 确认稳定并重算 → 保存。人工确认不是精度验收。",
+ "save": "保存分析结果",
"no_sites": "会话没有测点",
"candidate": "候选窗口",
"insufficient_samples": "有效样本不足",
@@ -590,7 +620,7 @@
"force_axis": "人工参考力 (N)",
"chart_title": "各保持窗口均值:加载/卸载对照",
"uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合",
- "issues": "已跳过 {count} 个未完成 trial;完整保持窗口仍须复核稳定性。",
+ "issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。",
"saved": "已另存:{path}",
"col_trial": "试验",
"col_branch": "阶段",
@@ -599,6 +629,7 @@
"col_mean": "原始均值",
"col_std": "原始标准差",
"col_duration": "保持 (s)",
- "col_quality": "质量说明"
+ "col_quality": "质量说明",
+ "col_drift": "漂移 (raw/s)"
}
}
diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md
index 6d2cbc8..ba1f99f 100644
--- a/apps/autocal/docs/calibration-data-format.zh.md
+++ b/apps/autocal/docs/calibration-data-format.zh.md
@@ -1,6 +1,6 @@
# Autocal 标定操作与数据格式草稿
-状态:2026-10-11 修订,阶段一仅针对 finger,待实机验收。已接通手动加载/卸载、多测点采集、记录总览和窗口复核;FT 同步及正式灵敏度结果仍待实现。
+状态:2026-10-11 修订,仅 finger,待实机验收。手动采集、记录总览及稳定段检查已接通;FT 同步及正式系数待实现。
## 范围与坐标
@@ -52,7 +52,7 @@
## 目录与文件
-本场目录名为 `<产品型号>___<唯一标识>`,完整身份保存在 manifest。每个 trial 是一个测点的一次完整循环,档位用编号及时间窗口区分,不按力值拆原始视频目录。
+目录名为 `<产品型号>___<唯一标识>`,身份保存在 manifest。一个 trial 对应同点一次循环;档位用编号和时间区间区分,不拆视频。
```text
finger_F001_<时间>_<标识>/
@@ -73,7 +73,7 @@ finger_F001_<时间>_<标识>/
| 文件 | 内容及写入时机 |
|---|---|
-| 根 `session.json` | `format_version=autocal-session-1.0`、字符串 session_id、型号/SN、配置快照及哈希、UV 定义、sites、trials、状态;首次开始、每档保持结束、trial 结束和整场关闭时更新 |
+| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 |
| trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、steps;采集 session_id 是独立的整数编号 |
| `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 |
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
@@ -108,15 +108,17 @@ finger_F001_<时间>_<标识>/
主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。
-“标定记录”页打开会话目录;采集页“查看标定数据…”也跳转至此。点击 UV 点或表格,联动响应及 trial 筛选,再“查看信号/复核窗口…”。
+“标定记录”页打开会话,核对型号/SN、时间、来源和采集状态。选点及单次试验,先看时间曲线及诊断,再“检查保持段…”,最后“保存分析结果”。采集页“查看标定数据…”也跳转至此。
| 总览 | 规则 |
|---|---|
| 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 |
-| 采集状态 | 未采、有未完成试验、待复核、已复核;完成次数及已确认窗口数分别列出,不把未完成 trial 用于拟合 |
+| 状态 | 采集完整性与稳定段检查分别展示;未完成 trial 可查看曲线、结束原因及缺失文件,不参与拟合 |
+| UV 轮廓 | 新采集的 uv_outline 保存映射身份及传感面/彩色 patch 外轮廓;旧记录仅恢复映射验证一致的几何轮廓,缺失或不匹配时提示;显示保持像素等比例 |
+| 时间曲线 | FN_raw 及人工保持参考力;显示有效样本、无效/未关联数、录像丢帧、最大帧间隔;各 trial 独立时间原点,不代表 FT 同步 |
| 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 |
| 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 |
-| 保存恢复 | “另存总览与复核”保存计划快照及窗口;载入 review.json 同时恢复总览,无 overview.json 时仅恢复复核 |
+| 保存恢复 | 保存计划及窗口快照;载入 review.json 恢复总览,无 overview.json 时仅恢复窗口;未保存修改有提示 |
拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。
@@ -124,12 +126,12 @@ finger_F001_<时间>_<标识>/
实时 UV 图有真实背景、位移箭头、深度三个开关;关闭背景显示纯色画布。深度按当前帧有效 UV 有限值范围自适应,显示范围;跨帧同色不代表同值,算法单位不是毫米。缺失或过期字段不绘制并提示。显示不改变录像及坐标。
-复核时选中表格中的保持段,检查下方原始样本图;起点、终点以该段保持开始为 0 秒,只能在原保持段内调整。勾选“已检查此段稳定”,点击“应用窗口并重算”,最后“另存分析结果”。至少有两个不同载荷的已确认加载/零载窗口时,显示已确认窗口拟合;不足时明确显示候选拟合。人工确认只表示看过稳定性,不证明参考力准确或物理同步。
+检查保持段的原始曲线、样本数、标准差和漂移 raw/s;起止时间以保持开始为零,只能在原段内调整。勾选“此时间段已检查稳定”,点击“确认选中时间段并重算”,再保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合,否则显示候选拟合。确认稳定不代表精度合格;尚无自动验收阈值。
-分析结果保存 selections、候选与已确认拟合、原始样本轨迹、输入 CSV/manifest 的 SHA256、配置快照及分析包版本;每个已确认初始零载段保存基准图像及帧身份、图像哈希。点击“载入已保存复核…”选 review.json 恢复窗口并重算;输入身份或哈希改变时拒绝载入及保存。新增结果另存为独立目录,不修改 reference.csv 的采集声明。
+分析保存 selections、拟合、样本轨迹、输入 SHA256、配置及版本;确认的初始零载段保存基准图像、帧身份及哈希。“载入历史分析…”选 review.json 重算;输入身份或哈希变化时拒绝载入及保存。结果另存,不修改 reference.csv。
-“重复性与验证…”只用已确认窗口,结果保存在各 site 的 quality:同分支、相同人工参考力跨 trial 的极差/样本标准差;同 trial 同参考力的卸载减加载 raw 差;最终零载减初始零载。配对要求参考力完全相同,不插值;不足两次 trial 不计算重复性。
+“重复性与验证…”使用已确认窗口,site.quality 保存跨 trial 同分支/参考力的极差和样本标准差、同 trial 卸载减加载 raw 差及最终减初始零载。参考力须完全相同,不插值;不足两次不算重复性。
-独立验证逐次留出一个完整 trial,用其余 trial 的零载/加载窗口拟合,再预测留出 trial 的全部已确认窗口。每窗口等权,报告 RMSE/MAE/最大绝对误差及超出训练 raw 范围的数量;训练档位不足时列明原因。交叉验证仍不能代替另一场独立实验,也不生成正式实时系数。
+验证逐次留出完整 trial,用其余 trial 的零载/加载拟合预测留出窗口;窗口等权,报告 RMSE/MAE/最大误差及超出训练范围数量,训练不足时说明原因。交叉验证不能代替独立实验或正式实时系数。
跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。
diff --git a/apps/autocal/src/orisys_autocal/calibration_session.py b/apps/autocal/src/orisys_autocal/calibration_session.py
index 710b134..fddcb1b 100644
--- a/apps/autocal/src/orisys_autocal/calibration_session.py
+++ b/apps/autocal/src/orisys_autocal/calibration_session.py
@@ -51,6 +51,7 @@ class CalibrationSession:
"sensor_sn": metadata["sensor_sn"],
"config_sha256": metadata["config_sha256"],
"uv_definition": metadata["uv_definition"],
+ "uv_outline": metadata.get("uv_outline"),
}
def add_trial(self, metadata: dict) -> dict:
diff --git a/apps/autocal/src/orisys_autocal/record_diagnostics.py b/apps/autocal/src/orisys_autocal/record_diagnostics.py
new file mode 100644
index 0000000..5676eb0
--- /dev/null
+++ b/apps/autocal/src/orisys_autocal/record_diagnostics.py
@@ -0,0 +1,56 @@
+"""Read trial evidence for inspection, including attempts excluded from fitting."""
+
+import csv
+import json
+import math
+from pathlib import Path
+
+
+def trial_diagnostics(root: Path, trial: dict) -> dict:
+ result = {"trial_id": trial["trial_id"], "status": trial["status"],
+ "reason": trial.get("reason", ""), "trace": [], "references": [], "events": [],
+ "frames": 0, "invalid": 0, "dropped": 0, "max_gap_s": None, "errors": [], "breaks_ns": []}
+ path = (root / trial.get("directory", "__missing_trial__")).resolve()
+ if not path.is_relative_to(root.resolve()):
+ raise ValueError("trial directory is outside the calibration session")
+
+ def rows(name):
+ try:
+ with (path / name).open(encoding="utf-8-sig", newline="") as handle:
+ return list(csv.DictReader(handle))
+ except OSError:
+ result["errors"].append(name)
+ return []
+
+ frames = rows("frames.csv")
+ frame_by_id = {row["frame_id"]: row for row in frames if row.get("frame_id")}
+ result["frames"] = len(frames)
+ result["dropped"] = sum(r.get("dropped") == "1" for r in frames)
+ stamps = {}
+ try:
+ for row in frames:
+ if row.get("t_host_mono_ns") and row.get("dropped") == "0":
+ stamps[int(row["frame_id"])] = int(row["t_host_mono_ns"])
+ ordered = sorted(stamps.values())
+ result["max_gap_s"] = max((b - a for a, b in zip(ordered, ordered[1:])), default=0) / 1e9
+ for row in rows("forces.csv"):
+ stamp = stamps.get(int(row["frame_id"]))
+ raw = float(row["FN_raw"]) if row.get("FN_raw") else math.nan
+ if row.get("valid") != "1" or not math.isfinite(raw) or stamp is None:
+ result["invalid"] += 1
+ frame = frame_by_id.get(row["frame_id"])
+ if frame and frame.get("t_host_mono_ns"):
+ result["breaks_ns"].append(int(frame["t_host_mono_ns"]))
+ else:
+ result["trace"].append([stamp, raw])
+ result["trace"].sort()
+ result["breaks_ns"].sort()
+ result["references"] = rows("reference.csv")
+ result["events"] = rows("events.csv")
+ metadata = json.loads((path / "session.json").read_text(encoding="utf-8"))
+ result["source"] = metadata.get("input_source", {})
+ result["time_basis"] = metadata.get("time_basis", "")
+ result["reason"] = metadata.get("trial_reason") or result["reason"]
+ except (OSError, ValueError, KeyError, TypeError) as exc:
+ result["errors"].append(str(exc))
+ return result
diff --git a/apps/autocal/src/orisys_autocal/sensor/uv_outline.py b/apps/autocal/src/orisys_autocal/sensor/uv_outline.py
new file mode 100644
index 0000000..43445d7
--- /dev/null
+++ b/apps/autocal/src/orisys_autocal/sensor/uv_outline.py
@@ -0,0 +1,45 @@
+"""Recorded UV region outlines; geometry recovery never substitutes a texture."""
+
+import cv2
+import numpy as np
+
+from .export_bundle import load_bundle
+from .uv_flat import mapping_sha256, uv_origin, rasterise
+
+
+def outline_snapshot(flat) -> dict:
+ def contours(mask):
+ rings, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+ return [cv2.approxPolyDP(ring, .5, True).reshape(-1, 2).tolist()
+ for ring in rings if len(ring) >= 3]
+
+ valid = flat.face_index >= 0
+ return {
+ "mapping_sha256": mapping_sha256(flat), "origin_raster_xy_px": list(uv_origin(flat)),
+ "raster_size_hw": list(flat.size_hw), "surface": contours(valid),
+ # External contours intentionally omit individual white marker cells.
+ "patch": contours(valid & (flat.image_rgb.min(axis=2) < 235)) if flat.textured else [],
+ "patch_available": bool(flat.textured),
+ }
+
+
+def recorded_outline(review: dict) -> tuple[dict | None, str]:
+ definition = review["uv_definition"]
+ snapshot = review.get("uv_outline")
+ if snapshot:
+ if any(snapshot[key] != definition[key] for key in (
+ "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
+ return None, "mismatch"
+ return snapshot, "snapshot" if snapshot["patch_available"] else "surface_only"
+ site = next((s for s in review["sites"] if s.get("bundle_path")), None)
+ if site is None:
+ return None, "unavailable"
+ try:
+ flat = rasterise(load_bundle(site["bundle_path"]), tuple(definition["raster_size_hw"]))
+ snapshot = outline_snapshot(flat)
+ except (OSError, ValueError, KeyError, RuntimeError):
+ return None, "unavailable"
+ if any(snapshot[key] != definition[key] for key in (
+ "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
+ return None, "mismatch"
+ return snapshot, "surface_only"
diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py
index cedec4b..9055076 100644
--- a/apps/autocal/src/orisys_autocal/session_review.py
+++ b/apps/autocal/src/orisys_autocal/session_review.py
@@ -200,6 +200,10 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"trace": trace,
"raw_mean": float(raw.mean()) if raw.size else None,
"raw_std": float(raw.std()) if raw.size else None,
+ "raw_drift_per_s": float(np.polyfit(
+ np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace
+ if selected_start <= stamp < selected_end]), raw, 1)[0])
+ if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None,
"stability_verified": verified,
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
@@ -224,6 +228,8 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
+ "sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
+ "started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
}
diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py
index 59fc585..5caa64e 100644
--- a/apps/autocal/src/orisys_autocal/trial.py
+++ b/apps/autocal/src/orisys_autocal/trial.py
@@ -84,6 +84,7 @@ class ManualTrial:
source: dict,
config_ref: str,
fps: float,
+ uv_outline: dict | None = None,
) -> Path:
if self.status in {"recording", "closing"}:
raise RuntimeError("a trial is already active")
@@ -141,7 +142,11 @@ class ManualTrial:
"reference_alignment_possible": self.reference_alignment_possible,
"reference_source": "manual_operator_confirmation",
"reference_file": "reference.csv",
+ "uv_outline": uv_outline,
}
+ if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key]
+ for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
+ raise ValueError("UV outline does not match the selected site mapping")
recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py
index f6116ef..5c54d5a 100644
--- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py
+++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
)
from ..sensor.uv_flat import site_at, uv_origin
+from ..sensor.uv_outline import outline_snapshot
from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView
from .live_force_view import LiveForceView
@@ -199,6 +200,7 @@ class CalibrationPanel(QWidget):
if self.trial.session is not None:
raise RuntimeError(t("calibration.close_before_mapping"))
self._flat = flat
+ self._uv_outline = outline_snapshot(flat)
self._bundle_path = bundle_path
self.flat_view.set_flat(flat)
origin_x, origin_y = uv_origin(flat)
@@ -275,6 +277,7 @@ class CalibrationPanel(QWidget):
source=source,
config_ref=self.source_panel.config_ref.text().strip(),
fps=mode.fps,
+ uv_outline=self._uv_outline,
)
self.views.setCurrentWidget(self.source_panel)
self.result_label.setText(
diff --git a/apps/autocal/src/orisys_autocal/ui/chart_helpers.py b/apps/autocal/src/orisys_autocal/ui/chart_helpers.py
new file mode 100644
index 0000000..be50991
--- /dev/null
+++ b/apps/autocal/src/orisys_autocal/ui/chart_helpers.py
@@ -0,0 +1,30 @@
+"""Shared value axes for record and hold inspection charts."""
+
+from PySide6.QtCharts import QValueAxis
+from PySide6.QtCore import Qt
+
+
+def clear_chart(chart):
+ chart.removeAllSeries()
+ for axis in chart.axes():
+ axis.hide()
+ chart.removeAxis(axis)
+ axis.deleteLater()
+
+
+def add_value_axes(chart):
+ points = [point for series in chart.series() for point in series.points()]
+ font = chart.font()
+ font.setPixelSize(14)
+ for horizontal in (True, False):
+ axis = QValueAxis()
+ axis.setLabelsFont(font)
+ axis.setTitleFont(font)
+ values = [p.x() if horizontal else p.y() for p in points]
+ if values:
+ low, high = min(values), max(values)
+ margin = max((high - low) * .1, .1)
+ axis.setRange(low - margin, high + margin)
+ chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
+ for series in chart.series():
+ series.attachAxis(axis)
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 9e099f4..afde61f 100644
--- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py
+++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py
@@ -1,19 +1,29 @@
"""Recorded-site coverage and normal response, with optional operator CSV plan."""
from pathlib import Path
+from datetime import datetime
+import statistics
+import math
+from bisect import bisect_left, bisect_right
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
- QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
+ QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabWidget,
+ QSizePolicy, QToolButton, QMenu,
+ QGraphicsSimpleTextItem,
+ QScrollArea, QFrame,
)
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
from ..session_review import review_session
+from ..record_diagnostics import trial_diagnostics
+from ..sensor.uv_outline import recorded_outline
from .session_review_dialog import SessionReviewDialog
+from .chart_helpers import add_value_axes, clear_chart
class RecordOverviewPage(QWidget):
@@ -27,12 +37,20 @@ class RecordOverviewPage(QWidget):
self.open_button = QPushButton(self)
self.open_button.setObjectName("openCalibrationRecords")
self.open_button.clicked.connect(self._open)
+ self.open_button.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
+ self.open_button.setStyleSheet("font-size: 14px; min-height: 22px; padding: 3px 10px;")
self.path_label = QLabel(self)
self.path_label.setWordWrap(True)
self.message = QLabel(self)
self.message.setWordWrap(True)
- self.content_layout.addWidget(self.open_button)
- self.content_layout.addWidget(self.path_label)
+ self.message.hide()
+ self.viewer = QScrollArea(self)
+ self.viewer.setWidgetResizable(True)
+ self.viewer.setFrameShape(QFrame.Shape.NoFrame)
+ header = QHBoxLayout()
+ header.addWidget(self.open_button)
+ header.addWidget(self.path_label, 1)
+ self.content_layout.addLayout(header)
self.content_layout.addWidget(self.message)
self.content_layout.addStretch(1)
self.retranslate()
@@ -52,19 +70,23 @@ class RecordOverviewPage(QWidget):
overview = RecordOverviewDialog(directory, self)
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
+ self.message.show()
return
overview.setWindowFlags(Qt.WindowType.Widget)
if self.overview is not None:
- self.content_layout.removeWidget(self.overview)
+ self.viewer.takeWidget()
self.overview.hide()
self.overview.deleteLater()
else:
self.content_layout.takeAt(self.content_layout.count() - 1)
+ self.content_layout.addWidget(self.viewer, 1)
self.overview = overview
self.directory = directory
- self.path_label.setText(str(directory))
+ self.path_label.setText(directory.name)
+ self.path_label.setToolTip(str(directory))
self.message.clear()
- self.content_layout.addWidget(overview, 1)
+ self.message.hide()
+ self.viewer.setWidget(overview)
overview.show()
@@ -77,38 +99,79 @@ class RecordOverviewDialog(QDialog):
super().__init__(parent)
self.directory = directory
self.review = review_session(directory)
+ self.diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]}
+ self.outline, self.outline_state = recorded_outline(self.review)
+ self._uv_bounds = None
+ self._uv_label = None
+ self._fitting_uv = False
+ self._dirty = False
+ self._selected_site_id = None
self.plan = None
self.rows = []
self.setWindowTitle(t("overview.title"))
self.resize(1240, 850)
+ self.setObjectName("recordOverview")
+ self.setStyleSheet("#recordOverview QLabel, #recordOverview QTableWidget, #recordOverview QComboBox, "
+ "#recordOverview QPushButton, #recordOverview QToolButton {font-size: 14px;} "
+ "#recordOverview QPushButton, #recordOverview QComboBox {min-height: 22px; padding: 3px 10px;} "
+ "#recordOverview QHeaderView::section {font-size: 13px;}")
layout = QVBoxLayout(self)
- self.notice = QLabel(t("overview.notice"), self)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+ self.identity = QLabel(self)
+ self.identity.setWordWrap(True)
+ layout.addWidget(self.identity)
+ self.notice = QLabel(self)
self.notice.setWordWrap(True)
+ self.notice.setToolTip(t("overview.notice"))
layout.addWidget(self.notice)
toolbar = QHBoxLayout()
self.map_mode = QComboBox(self)
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
toolbar.addWidget(self.map_mode)
- for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan),
- ("load", self._load), ("save", self._save)):
- button = QPushButton(t("overview." + name), self)
- button.setObjectName("overview_" + name)
- button.clicked.connect(slot)
- toolbar.addWidget(button)
+ self.map_mode.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
+ self.tools = QToolButton(self)
+ 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))
+ action.setObjectName("overview_" + name)
+ action.triggered.connect(slot)
+ self.tools.setMenu(menu)
+ toolbar.addWidget(self.tools)
+ toolbar.addStretch(1)
layout.addLayout(toolbar)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
charts = QHBoxLayout()
self.uv_chart, self.response_chart = QChart(), QChart()
+ self.timeline_chart = QChart()
self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart)
- for view in (self.uv_view, self.response_view):
+ self.timeline_view = QChartView(self.timeline_chart)
+ for view in (self.uv_view, self.response_view, self.timeline_view):
view.setRenderHint(QPainter.RenderHint.Antialiasing)
- charts.addWidget(self.uv_view, 1)
- charts.addWidget(self.response_view, 2)
+ view.chart().setMinimumSize(0, 0)
+ view.setMinimumHeight(260)
+ font = view.chart().font()
+ font.setPixelSize(14)
+ view.chart().setFont(font)
+ uv_column = QVBoxLayout()
+ uv_column.addWidget(self.uv_view, 1)
+ self.outline_message = QLabel(t("overview.outline_" + self.outline_state), self)
+ self.outline_message.setWordWrap(True)
+ uv_column.addWidget(self.outline_message)
+ charts.addLayout(uv_column, 2)
+ self.plots = QTabWidget(self)
+ self.plots.addTab(self.timeline_view, t("overview.timeline"))
+ self.plots.addTab(self.response_view, t("overview.response"))
+ charts.addWidget(self.plots, 3)
+ self.uv_chart.plotAreaChanged.connect(self._fit_uv_axes)
layout.addLayout(charts, 2)
self.table = QTableWidget(0, 11, self)
self.table.setObjectName("overviewSites")
+ self.table.setMinimumHeight(125)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
@@ -116,28 +179,54 @@ class RecordOverviewDialog(QDialog):
"order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
+ for column in (0, 3, 9, 10):
+ self.table.setColumnHidden(column, True)
layout.addWidget(self.table, 1)
selection = QHBoxLayout()
self.trials = QComboBox(self)
self.trials.setObjectName("overviewTrial")
self.details_button = QPushButton(t("overview.details"), self)
selection.addWidget(QLabel(t("overview.trial_filter"), self))
- selection.addWidget(self.trials, 1)
+ selection.addWidget(self.trials)
+ selection.addStretch(1)
selection.addWidget(self.details_button)
+ self.save_button = QPushButton(t("overview.save"), self)
+ self.save_button.setObjectName("overview_save")
+ self.save_button.clicked.connect(self._save)
+ selection.addWidget(self.save_button)
layout.addLayout(selection)
self.selected_summary = QLabel(self)
self.selected_summary.setWordWrap(True)
layout.addWidget(self.selected_summary)
+ self.diagnostic_summary = QLabel(self)
+ self.diagnostic_summary.setWordWrap(True)
+ layout.addWidget(self.diagnostic_summary)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
+ self.selected_summary.hide()
+ self.plots.currentChanged.connect(lambda index: self.selected_summary.setVisible(index == 1))
self.table.itemSelectionChanged.connect(self._select_site)
self.map_mode.currentIndexChanged.connect(self._show_uv)
self.trials.currentIndexChanged.connect(self._show_response)
+ self.trials.currentIndexChanged.connect(self._show_timeline)
self.details_button.clicked.connect(self._details)
self._refresh()
def _refresh(self):
+ sources = sorted({d.get("source", {}).get("kind", "") for d in self.diagnostics.values()} - {""})
+ date = self.review.get("started_at")
+ if date:
+ try:
+ date = datetime.fromisoformat(date).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
+ except ValueError:
+ pass
+ self.identity.setText(t("overview.identity").format(
+ model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—",
+ date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—",
+ status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress"
+ else t("calibration." + self.review["session_status"])))
+ self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory))
selected = self.table.currentRow()
self.rows = overview_rows(self.review, self.plan)
self.table.blockSignals(True)
@@ -152,25 +241,31 @@ class RecordOverviewDialog(QDialog):
f"{row['confirmed_windows']}/{row['windows']}",
", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity,
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])]
+ analysis = "reviewed" if row["windows"] and row["confirmed_windows"] == row["windows"] else "pending" if row["windows"] else "no_analysis"
+ values[4] = t("overview.analysis_" + analysis)
for column, value in enumerate(values):
self.table.setItem(index, column, QTableWidgetItem(value))
+ self.table.item(index, 1).setToolTip(t("overview.site_details").format(
+ xyz=values[3], attempts=values[5], range=values[9], trials=values[10]))
self.table.blockSignals(False)
collected = sum(row["trials"] > 0 for row in self.rows)
self.summary.setText(t("overview.summary").format(
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—",
- missing=sum(r["status"] == "uncollected" for r in self.rows),
+ missing=sum(r["status"] == "uncollected" for r in self.rows) if self.plan else "—",
incomplete=sum(r["status"] == "incomplete" for r in self.rows),
pending=sum(r["status"] == "pending" for r in self.rows),
reviewed=sum(r["status"] == "reviewed" for r in self.rows),
extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—",
) + ("\n" + t("overview.no_plan") if self.plan is None else ""))
- self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
+ self.message.setText(t("overview.unsaved") if self._dirty else "")
+ self.message.setVisible(self._dirty)
if self.rows:
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
self._select_site()
def _select_site(self):
index = self.table.currentRow()
+ previous = self.trials.currentData()
self.trials.blockSignals(True)
self.trials.clear()
self.trials.addItem(t("overview.all_trials"), None)
@@ -179,33 +274,22 @@ class RecordOverviewDialog(QDialog):
for trial in self.review["trials"]:
if site_id is not None and trial.get("site_id") == site_id:
self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"])
+ match = self.trials.findData(previous) if site_id == self._selected_site_id else -1
+ self.trials.setCurrentIndex(match if match >= 0 else 1 if self.trials.count() > 1 else 0)
+ self._selected_site_id = site_id
self.trials.blockSignals(False)
self._show_uv()
self._show_response()
+ self._show_timeline()
- @staticmethod
- def _clear_chart(chart):
- chart.removeAllSeries()
- for axis in chart.axes():
- axis.hide()
- chart.removeAxis(axis)
- axis.deleteLater()
-
- @staticmethod
- def _axes(chart):
- points = [point for series in chart.series() for point in series.points()]
- for horizontal in (True, False):
- axis = QValueAxis()
- values = [p.x() if horizontal else p.y() for p in points]
- if values:
- low, high = min(values), max(values)
- margin = max((high - low) * .1, .1)
- axis.setRange(low - margin, high + margin)
- chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
- for series in chart.series():
- series.attachAxis(axis)
+ _clear_chart = staticmethod(clear_chart)
+ _axes = staticmethod(add_value_axes)
def _show_uv(self):
+ self._uv_bounds = None
+ if self._uv_label is not None:
+ self.uv_chart.scene().removeItem(self._uv_label)
+ self._uv_label = None
self._clear_chart(self.uv_chart)
sensitivity = self.map_mode.currentIndex() == 1
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]]
@@ -226,21 +310,55 @@ class RecordOverviewDialog(QDialog):
series.append(*row["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
self.uv_chart.addSeries(series)
+ if index == self.table.currentRow():
+ self._uv_label = QGraphicsSimpleTextItem(row["site_id"] or str(row["orders"]), self.uv_chart)
+ self._uv_label.setFont(self.uv_chart.font())
+ self._uv_label.setZValue(10)
+ self._uv_label_point = QPointF(*row["uv_xy_px"])
self._axes(self.uv_chart)
- definition = self.review["uv_definition"]
- origin = definition.get("origin_raster_xy_px")
- shape = definition.get("raster_size_hw")
+ bounds = [tuple(row["uv_xy_px"]) for row in self.rows] + [(0, 0)]
+ if self.outline:
+ ox, oy = self.outline["origin_raster_xy_px"]
+ for region, color in (("surface", "#687585"), ("patch", "#719fb8")):
+ for ring in self.outline[region]:
+ series = QLineSeries()
+ series.setColor(QColor(color))
+ for x, y in ring + ring[:1]:
+ series.append(x - ox, oy - y)
+ bounds.append((x - ox, oy - y))
+ self.uv_chart.addSeries(series)
+ for axis in self.uv_chart.axes():
+ series.attachAxis(axis)
+ xs, ys = zip(*bounds)
+ self._uv_bounds = min(xs), max(xs), min(ys), max(ys)
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
- if origin and shape:
- axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5)
+ axis.setLabelFormat("%.0f")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
- if origin and shape:
- axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5)
+ axis.setLabelFormat("%.0f")
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
if sensitivity else t("overview.coverage_map"))
+ self._fit_uv_axes()
+
+ def _fit_uv_axes(self, *_args):
+ area = self.uv_chart.plotArea()
+ if self._fitting_uv or self._uv_bounds is None or area.width() <= 0 or area.height() <= 0:
+ return
+ self._fitting_uv = True
+ try:
+ left, right, bottom, top = self._uv_bounds
+ scale = max(max(right - left, 2) / area.width(), max(top - bottom, 2) / area.height()) * 1.12
+ for horizontal in (True, False):
+ axis = self.uv_chart.axes(Qt.Orientation.Horizontal if horizontal else Qt.Orientation.Vertical)[0]
+ center = (left + right) / 2 if horizontal else (bottom + top) / 2
+ span = scale * (area.width() if horizontal else area.height())
+ axis.setRange(center - span / 2, center + span / 2)
+ if self._uv_label is not None:
+ self._uv_label.setPos(self.uv_chart.mapToPosition(self._uv_label_point) + QPointF(8, -20))
+ finally:
+ self._fitting_uv = False
def _show_response(self):
self._clear_chart(self.response_chart)
@@ -249,7 +367,12 @@ class RecordOverviewDialog(QDialog):
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
trial_id = self.trials.currentData()
windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else []
- self.details_button.setEnabled(site is not None and bool(windows))
+ attempts = [t for t in self.review["trials"] if site is not None and t.get("site_id") == site_id
+ and (trial_id is None or t["trial_id"] == trial_id)]
+ self.details_button.setEnabled(bool(attempts))
+ self.details_button.setText(t("overview.details" if windows else "overview.inspect_incomplete"))
+ self.notice.setText(t("overview.guide_missing" if not attempts else "overview.guide_incomplete" if not windows
+ else "overview.guide_save" if self._dirty else "overview.guide_check"))
for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
for confirmed in (True, False):
@@ -286,6 +409,76 @@ class RecordOverviewDialog(QDialog):
basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
) if windows else t("overview.no_windows"))
+ def _show_timeline(self):
+ self._clear_chart(self.timeline_chart)
+ index = self.table.currentRow()
+ site_id = self.rows[index]["site_id"] if index >= 0 else None
+ trial_id = self.trials.currentData()
+ reports = [self.diagnostics[t["trial_id"]] for t in self.review["trials"]
+ if site_id is not None and t.get("site_id") == site_id
+ and (trial_id is None or t["trial_id"] == trial_id)]
+ summaries, reference_series = [], []
+ for report in reports:
+ trace = report["trace"]
+ summaries.append(t("overview.diagnostic").format(
+ trial=report["trial_id"], status=t("calibration." + report["status"]),
+ samples=len(trace), invalid=report["invalid"], dropped=report["dropped"],
+ gap=f"{report['max_gap_s']:.3g}" if report["max_gap_s"] is not None else "—",
+ reason=report["reason"] or "—"))
+ if report["errors"]:
+ summaries.append(t("overview.read_errors").format(detail=", ".join(report["errors"])))
+ if not trace:
+ continue
+ origin = trace[0][0]
+ gaps = [(b[0] - a[0]) / 1e9 for a, b in zip(trace, trace[1:]) if b[0] > a[0]]
+ limit = max(statistics.median(gaps) * 5, .25) if gaps else .25
+ series = None
+ previous = None
+ for stamp, raw in trace:
+ if series is None or (previous is not None and ((stamp - previous) / 1e9 > limit
+ or bisect_right(report["breaks_ns"], previous) < bisect_left(report["breaks_ns"], stamp))):
+ series = QLineSeries()
+ series.setName(report["trial_id"])
+ series.setColor(QColor("#167ac6"))
+ series.setPointsVisible(True)
+ self.timeline_chart.addSeries(series)
+ series.append((stamp - origin) / 1e9, raw)
+ previous = stamp
+ for ref in report["references"]:
+ try:
+ start, end = int(ref["t_start_host_mono_ns"]), int(ref["t_end_host_mono_ns"])
+ force = float(ref["F_ref_normal_N"])
+ except (KeyError, ValueError, TypeError):
+ continue
+ if end <= start or not math.isfinite(force):
+ continue
+ series = QLineSeries()
+ series.setColor(QColor("#df8b13"))
+ series.append((start - origin) / 1e9, force)
+ series.append((end - origin) / 1e9, force)
+ reference_series.append(series)
+ self._axes(self.timeline_chart)
+ for axis in self.timeline_chart.axes(Qt.Orientation.Horizontal):
+ axis.setTitleText(t("overview.time_axis"))
+ for axis in self.timeline_chart.axes(Qt.Orientation.Vertical):
+ axis.setTitleText(t("review.raw_axis"))
+ if reference_series:
+ reference_axis = QValueAxis()
+ reference_axis.setTitleText(t("overview.reference_axis"))
+ forces = [p.y() for series in reference_series for p in series.points()]
+ reference_axis.setRange(min(0, min(forces)), max(1, max(forces)) * 1.1)
+ self.timeline_chart.addAxis(reference_axis, Qt.AlignmentFlag.AlignRight)
+ horizontal = self.timeline_chart.axes(Qt.Orientation.Horizontal)[0]
+ for series in reference_series:
+ self.timeline_chart.addSeries(series)
+ series.attachAxis(horizontal)
+ series.attachAxis(reference_axis)
+ self.timeline_chart.legend().hide()
+ self.timeline_chart.setTitle(t("overview.timeline_title") if any(r["trace"] for r in reports) else t("overview.no_trace"))
+ self.selected_summary.setToolTip("\n".join(summaries))
+ self.diagnostic_summary.setText("\n".join(summaries) if len(reports) == 1
+ else t("overview.choose_trial") if reports else t("overview.guide_missing"))
+
def _import_plan(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
if path:
@@ -293,17 +486,26 @@ class RecordOverviewDialog(QDialog):
plan = read_plan(Path(path), self.review["uv_definition"])
overview_rows(self.review, plan)
self.plan = plan
+ self._dirty = True
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
+ self.message.show()
def _clear_plan(self):
self.plan = None
+ self._dirty = True
self._refresh()
def _details(self):
row = self.rows[self.table.currentRow()]
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"])
+ trial = self.trials.currentData()
+ if not any(trial is None or w["trial_id"] == trial for w in self.review["sites"][index]["windows"]):
+ self.plots.setCurrentIndex(0)
+ self.notice.setText(t("overview.guide_incomplete"))
+ return
+ before = dict(self.review["selections"])
dialog = SessionReviewDialog(self.directory, self, review=self.review)
dialog.sites.setCurrentIndex(index)
trial = self.trials.currentData()
@@ -314,14 +516,20 @@ class RecordOverviewDialog(QDialog):
dialog.table.selectRow(match)
dialog.exec()
self.review = dialog.review
+ self._dirty = self._dirty or before != self.review["selections"]
self._refresh()
def _save(self):
try:
path = save_overview(self.review, self.plan)
- self.message.setText(t("review.saved").format(path=path))
+ self._dirty = False
+ self._show_response()
+ self.message.setText(t("overview.saved"))
+ self.message.show()
+ self.message.setToolTip(str(path))
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
+ self.message.show()
def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
@@ -329,6 +537,8 @@ class RecordOverviewDialog(QDialog):
try:
review, plan = load_overview(self.directory, Path(path))
self.review, self.plan = review, plan
+ self._dirty = False
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
+ self.message.show()
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 44692a0..fc6a175 100644
--- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py
+++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py
@@ -10,10 +10,11 @@ from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
- QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog,
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
)
from ..session_review import review_session, save_review, load_review
+from .chart_helpers import add_value_axes, clear_chart
class SessionReviewDialog(QDialog):
@@ -23,6 +24,10 @@ class SessionReviewDialog(QDialog):
super().__init__(parent)
self.setWindowTitle(t("review.title"))
self.resize(1080, 760)
+ self.setObjectName("holdInspection")
+ self.setStyleSheet("#holdInspection QLabel, #holdInspection QPushButton, #holdInspection QComboBox, "
+ "#holdInspection QTableWidget, #holdInspection QCheckBox {font-size: 14px;} "
+ "#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
self.review = review if review is not None else review_session(directory)
self.directory = directory
layout = QVBoxLayout(self)
@@ -41,6 +46,12 @@ class SessionReviewDialog(QDialog):
row.addWidget(self.save_button)
row.addWidget(self.load_button)
layout.addLayout(row)
+ self.pages = QTabWidget(self)
+ hold_page, response_page = QWidget(self), QWidget(self)
+ hold_layout, response_layout = QVBoxLayout(hold_page), QVBoxLayout(response_page)
+ self.pages.addTab(hold_page, t("overview.details"))
+ self.pages.addTab(response_page, t("overview.response"))
+ layout.addWidget(self.pages, 1)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
summary_row = QHBoxLayout()
@@ -49,7 +60,7 @@ class SessionReviewDialog(QDialog):
self.quality_button.setObjectName("reviewQuality")
self.quality_button.clicked.connect(self._quality)
summary_row.addWidget(self.quality_button)
- layout.addLayout(summary_row)
+ response_layout.addLayout(summary_row)
self.chart = QChart()
self.chart_view = QChartView(self.chart, self)
self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -59,15 +70,15 @@ class SessionReviewDialog(QDialog):
charts = QHBoxLayout()
charts.addWidget(self.uv_view, 1)
charts.addWidget(self.chart_view, 2)
- layout.addLayout(charts, 1)
- self.table = QTableWidget(0, 8, self)
+ response_layout.addLayout(charts, 1)
+ self.table = QTableWidget(0, 9, self)
self.table.setObjectName("reviewWindows")
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in (
- "trial", "branch", "load", "samples", "mean", "std", "duration", "quality")])
+ "trial", "branch", "load", "samples", "mean", "std", "duration", "quality", "drift")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
- layout.addWidget(self.table, 1)
+ hold_layout.addWidget(self.table, 1)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
editor = QHBoxLayout()
@@ -84,11 +95,11 @@ class SessionReviewDialog(QDialog):
editor.addWidget(self.end_s)
editor.addWidget(self.verified)
editor.addWidget(self.apply_button)
- layout.addLayout(editor)
+ hold_layout.addLayout(editor)
self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self)
- self.trace_view.setMinimumHeight(130)
- layout.addWidget(self.trace_view, 1)
+ self.trace_view.setMinimumHeight(240)
+ hold_layout.addWidget(self.trace_view, 2)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
@@ -102,9 +113,7 @@ class SessionReviewDialog(QDialog):
def _show_site(self) -> None:
index = self.sites.currentIndex()
self.quality_button.setEnabled(index >= 0)
- self.chart.removeAllSeries()
- for axis in self.chart.axes():
- self.chart.removeAxis(axis)
+ clear_chart(self.chart)
if index < 0:
self.summary.setText(t("review.no_sites"))
return
@@ -112,14 +121,14 @@ class SessionReviewDialog(QDialog):
windows = site["windows"]
self.table.setRowCount(len(windows))
for row, window in enumerate(windows):
- quality = t("review." + window["status"])
+ quality = t("review." + window["status"]) if window["status"] != "candidate" else t(
+ "review.verified" if window["stability_verified"] else "review.unverified")
if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay")
- quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified")
values = [window["trial_id"], t("calibration." + window["branch"]),
f"{window['reference_n']:g}", str(window["samples"]),
self._number(window["raw_mean"]), self._number(window["raw_std"]),
- f"{window['duration_s']:g}", quality]
+ f"{window['duration_s']:g}", quality, self._number(window["raw_drift_per_s"])]
for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value))
fit = site["confirmed_fit"] or site["fit"]
@@ -144,12 +153,11 @@ class SessionReviewDialog(QDialog):
for value in (min(raw), max(raw)):
line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"])
self.chart.addSeries(line)
- self.chart.createDefaultAxes()
+ add_value_axes(self.chart)
for axis in self.chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("review.raw_axis"))
for axis in self.chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.force_axis"))
- self._pad_axes(self.chart)
self.chart.setTitle(t("review.chart_title"))
self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
@@ -172,9 +180,7 @@ class SessionReviewDialog(QDialog):
def _show_window(self):
window = self._current_window()
- self.trace_chart.removeAllSeries()
- for axis in self.trace_chart.axes():
- self.trace_chart.removeAxis(axis)
+ clear_chart(self.trace_chart)
self.apply_button.setEnabled(window is not None)
if window is None:
return
@@ -199,7 +205,7 @@ class SessionReviewDialog(QDialog):
line.append((stamp - origin) / 1e9, low - margin)
line.append((stamp - origin) / 1e9, high + margin)
self.trace_chart.addSeries(line)
- self.trace_chart.createDefaultAxes()
+ add_value_axes(self.trace_chart)
for axis in self.trace_chart.axes(Qt.Orientation.Horizontal):
axis.setRange(0, duration)
axis.setTitleText(t("review.hold_time_axis"))
@@ -243,9 +249,7 @@ class SessionReviewDialog(QDialog):
self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None:
- self.uv_chart.removeAllSeries()
- for axis in self.uv_chart.axes():
- self.uv_chart.removeAxis(axis)
+ clear_chart(self.uv_chart)
fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]]
values = [fit["sensitivity_raw_per_n"] for fit in fits if fit]
low, high = (min(values), max(values)) if values else (0, 0)
@@ -263,22 +267,14 @@ class SessionReviewDialog(QDialog):
series.append(*site["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target))
self.uv_chart.addSeries(series)
- self.uv_chart.createDefaultAxes()
+ add_value_axes(self.uv_chart)
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
- self._pad_axes(self.uv_chart)
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high))
- @staticmethod
- def _pad_axes(chart: QChart) -> None:
- for axis in chart.axes():
- low, high = axis.min(), axis.max()
- margin = max((high - low) * .1, .1)
- axis.setRange(low - margin, high + margin)
-
@staticmethod
def _number(value) -> str:
return "—" if value is None else f"{value:.6g}"
diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py
index 3310b35..f305114 100644
--- a/apps/autocal/tests/test_binding_purity.py
+++ b/apps/autocal/tests/test_binding_purity.py
@@ -61,6 +61,7 @@ _SCOPED_ENUMS = {
"TextElideMode",
"TextFlag",
"AspectRatioMode",
+ "WindowType",
),
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
"QSizePolicy": ("Policy",),
@@ -160,6 +161,12 @@ def test_no_module_uses_an_unscoped_enum():
)
+def test_window_type_guard_accepts_scoped_values_but_rejects_legacy_values(tmp_path):
+ path = tmp_path / "window_flags.py"
+ path.write_text("flag = Qt.WindowType.Widget\nlegacy = Qt.Widget\n", encoding="utf-8")
+ assert _unscoped_enum_references(path) == ["Qt.Widget"]
+
+
def test_the_controller_interface_is_qt_free():
"""The controller and its guards stay importable from a headless process.
diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py
index f1a75ed..ae48d13 100644
--- a/apps/autocal/tests/test_manual_trial.py
+++ b/apps/autocal/tests/test_manual_trial.py
@@ -111,6 +111,27 @@ def test_uv_coordinates_are_centered_and_masked(flat):
site_at(flat, -3, 0)
+def test_trial_persists_colour_region_outline_and_rejects_mismatched_mapping(trial_setup, flat):
+ from orisys_autocal.sensor.uv_outline import outline_snapshot
+
+ flat.textured = True
+ flat.image_rgb[:] = 255
+ flat.image_rgb[2:4, 2:5] = [20, 80, 200]
+ snapshot = outline_snapshot(flat)
+ worker = Worker()
+ trial = ManualTrial(worker)
+ with pytest.raises(ValueError, match="outline"):
+ trial.start(**trial_setup, uv_outline={**snapshot, "mapping_sha256": "wrong"})
+ directory = trial.start(**trial_setup, uv_outline=snapshot)
+ worker.emit()
+ trial.finish(aborted=True, reason="test ended")
+ trial.recorder.stop(wait=True)
+ trial.poll()
+ manifest = json.loads((directory.parent.parent / "session.json").read_text(encoding="utf-8"))
+ assert manifest["uv_outline"] == snapshot and snapshot["patch"]
+ trial.finish_session()
+
+
def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock):
worker = Worker()
trial = ManualTrial(worker)
@@ -277,6 +298,9 @@ def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock
panel.levels.setText("5, 10")
panel.start_button.click()
assert panel.trial.status == "recording"
+ manifest = json.loads((panel.trial.session.directory / "session.json").read_text(encoding="utf-8"))
+ assert manifest["uv_outline"]["surface"]
+ assert manifest["uv_outline"]["mapping_sha256"] == manifest["uv_definition"]["mapping_sha256"]
worker.emit()
assert panel.views.currentWidget() is panel.source_panel
assert not panel.sensor_sn.isEnabled()
diff --git a/apps/autocal/tests/test_record_diagnostics.py b/apps/autocal/tests/test_record_diagnostics.py
new file mode 100644
index 0000000..a7feaf2
--- /dev/null
+++ b/apps/autocal/tests/test_record_diagnostics.py
@@ -0,0 +1,84 @@
+"""Operator evidence remains inspectable without admitting incomplete fits."""
+
+import json
+import csv
+from copy import deepcopy
+
+import pytest
+import numpy as np
+from test_session_review import session as session
+from test_manual_trial import flat as flat
+
+from orisys_autocal.calibration_session import write_json
+from orisys_autocal.record_diagnostics import trial_diagnostics
+from orisys_autocal.session_review import review_session
+from orisys_autocal.sensor.uv_outline import outline_snapshot, recorded_outline
+
+
+def test_incomplete_trace_counts_and_reference_evidence_are_available(session):
+ manifest = json.loads((session / "session.json").read_text(encoding="utf-8"))
+ trial = manifest["trials"][0]
+ trial.update(status="incomplete", reason="operator stopped")
+ write_json(session / "session.json", manifest)
+ report = trial_diagnostics(session, trial)
+ assert report["frames"] == 25 and report["dropped"] == 5
+ assert report["invalid"] == 10 and len(report["trace"]) == 15
+ assert report["max_gap_s"] == 10
+ assert report["trace"][0] == [1_000_000_000, 1]
+ assert len(report["breaks_ns"]) == 10
+ assert report["reason"] == "operator stopped"
+ assert len(report["references"]) == 5
+ result = review_session(session)
+ assert result["sites"][0]["windows"] == [] and result["sites"][0]["fit"] is None
+
+
+def test_diagnostic_missing_files_and_external_path(session):
+ report = trial_diagnostics(session, {"trial_id": "missing", "status": "incomplete"})
+ assert report["trace"] == [] and "frames.csv" in report["errors"]
+ with pytest.raises(ValueError, match="outside"):
+ trial_diagnostics(session, {"trial_id": "bad", "status": "incomplete", "directory": "../outside"})
+
+
+def test_hold_drift_uses_selected_valid_timestamps(session):
+ path = session / "trials/trial-001/forces.csv"
+ with path.open(newline="", encoding="utf-8") as handle:
+ rows = list(csv.DictReader(handle))
+ next(row for row in rows if row["frame_id"] == "2")["FN_raw"] = "3"
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
+ writer.writeheader()
+ writer.writerows(rows)
+ window = review_session(session)["sites"][0]["windows"][0]
+ assert window["samples"] == 2 and window["raw_mean"] == 2
+ assert window["raw_drift_per_s"] == pytest.approx(2)
+
+
+def test_outline_snapshot_preserves_region_coordinates_and_mapping(flat):
+ flat.textured = True
+ flat.image_rgb[:] = 255
+ flat.image_rgb[2:4, 2:5] = [20, 80, 200]
+ snapshot = outline_snapshot(flat)
+ assert snapshot["surface"] and snapshot["patch"]
+ assert snapshot["origin_raster_xy_px"] == [3, 3]
+ assert snapshot["patch"][0] == [[2, 2], [2, 3], [4, 3], [4, 2]]
+ definition = {k: snapshot[k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}
+ review = {"uv_definition": definition, "uv_outline": snapshot, "sites": []}
+ assert recorded_outline(review) == (snapshot, "snapshot")
+ changed = deepcopy(review)
+ changed["uv_definition"]["mapping_sha256"] = "different"
+ assert recorded_outline(changed) == (None, "mismatch")
+ assert recorded_outline({"uv_definition": definition, "sites": []}) == (None, "unavailable")
+
+
+def test_legacy_geometry_recovery_checks_actual_mapping(flat, monkeypatch):
+ from orisys_autocal.sensor import uv_outline
+
+ snapshot = outline_snapshot(flat)
+ review = {"uv_definition": {k: snapshot[k] for k in (
+ "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}, "sites": [{"bundle_path": "recorded-bundle"}]}
+ monkeypatch.setattr(uv_outline, "load_bundle", lambda path: path)
+ monkeypatch.setattr(uv_outline, "rasterise", lambda bundle, shape: flat)
+ result, state = recorded_outline(review)
+ assert state == "surface_only" and not result["patch_available"]
+ flat.positions_mm[1, 2] += np.array([0, 0, 1])
+ assert recorded_outline(review) == (None, "mismatch")
diff --git a/apps/autocal/tests/test_record_overview.py b/apps/autocal/tests/test_record_overview.py
index 1e4f4ca..78cf133 100644
--- a/apps/autocal/tests/test_record_overview.py
+++ b/apps/autocal/tests/test_record_overview.py
@@ -6,6 +6,7 @@ from copy import deepcopy
import pytest
from PySide6.QtWidgets import QPushButton
+from PySide6.QtGui import QAction
from test_session_review import repeated_session as repeated_session, session as session
from orisys_autocal.calibration_session import write_json
@@ -20,6 +21,7 @@ def overview_session(repeated_session):
definition = {"mapping_sha256": "test-map", "origin_raster_xy_px": [5, 8],
"raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"}
manifest["uv_definition"] = definition
+ manifest.update(sensor_model="finger", sensor_sn="F-TEST", started_at="2026-10-11T09:00:00+00:00")
for index, site in enumerate(manifest["sites"]):
site["position_sensor_mm"] = [index + 1., 2., 3.]
for trial in manifest["trials"]:
@@ -27,6 +29,7 @@ def overview_session(repeated_session):
path = root / trial["directory"] / "session.json"
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["uv_definition"] = definition
+ metadata["input_source"] = {"kind": "video", "value": "test.mp4"}
write_json(path, metadata)
trial["plan"] = {"steps": list(range(5))}
manifest["sites"].append({"site_id": "site-003", "uv_xy_px": [3, 2], "position_sensor_mm": [3., 2., 3.]})
@@ -109,7 +112,7 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
dialog.review = review_session(root, selections=selections)
dialog._refresh()
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), ""))
- dialog.findChild(QPushButton, "overview_import_plan").click()
+ dialog.findChild(QAction, "overview_import_plan").trigger()
assert dialog.table.rowCount() == 4
assert dialog.table.item(0, 1).text() == "site-002"
dialog.uv_chart.series()[1].clicked.emit(dialog.uv_chart.series()[1].at(0))
@@ -135,7 +138,9 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
dialog.table.selectRow(3)
dialog.trials.setCurrentIndex(1)
assert not dialog.response_chart.series()
- assert not dialog.details_button.isEnabled()
+ assert dialog.details_button.isEnabled()
+ dialog.details_button.click()
+ assert dialog.plots.currentIndex() == 0
dialog.table.selectRow(2)
dialog.map_mode.setCurrentIndex(1)
assert len(dialog.uv_chart.series()) == 4
@@ -151,10 +156,10 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click()
saved = next(root.glob("derived/*/review.json"))
- dialog.findChild(QPushButton, "overview_clear_plan").click()
+ dialog.findChild(QAction, "overview_clear_plan").trigger()
assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), ""))
- dialog.findChild(QPushButton, "overview_load").click()
+ dialog.findChild(QAction, "overview_load").trigger()
assert dialog.table.rowCount() == 4
dialog.close()
@@ -198,4 +203,43 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk
QTest.keyClick(page.overview, Qt.Key.Key_Escape)
assert page.overview.isVisible()
assert window.grab().save(str(tmp_path / "records-tab.png"))
+ window.resize(1280, 900)
+ qt_app.processEvents()
+ page.viewer.ensureWidgetVisible(page.overview.save_button)
+ assert page.overview.save_button.isVisible()
+ assert window.grab().save(str(tmp_path / "records-tab-small.png"))
window.close()
+
+
+def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app, overview_session, tmp_path):
+ from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
+ from PySide6.QtCore import Qt
+
+ root, _, _ = overview_session
+ manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
+ manifest["uv_outline"] = {
+ **{k: manifest["uv_definition"][k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")},
+ "surface": [[[1, 1], [10, 1], [10, 10], [1, 10]]],
+ "patch": [[[2, 5], [9, 5], [9, 9], [2, 9]]], "patch_available": True,
+ }
+ manifest["trials"][0].update(status="incomplete", reason="operator stopped")
+ write_json(root / "session.json", manifest)
+ dialog = RecordOverviewDialog(root)
+ assert "F-TEST" in dialog.identity.text() and "finger" in dialog.identity.text()
+ assert dialog.outline_state == "snapshot"
+ assert dialog.trials.currentData() == "trial-001"
+ assert dialog.details_button.isEnabled()
+ assert dialog.diagnostics["trial-001"]["trace"]
+ assert "operator stopped" in dialog.diagnostic_summary.text()
+ dialog.details_button.click()
+ assert dialog.plots.currentIndex() == 0
+ assert not dialog.response_chart.series()
+ dialog.show()
+ qt_app.processEvents()
+ area = dialog.uv_chart.plotArea()
+ x = dialog.uv_chart.axes(Qt.Orientation.Horizontal)[0]
+ y = dialog.uv_chart.axes(Qt.Orientation.Vertical)[0]
+ assert (x.max() - x.min()) / area.width() == pytest.approx((y.max() - y.min()) / area.height(), rel=.02)
+ assert len(dialog.uv_chart.series()) == 5
+ assert dialog.grab().save(str(tmp_path / "operator-incomplete.png"))
+ dialog.close()
diff --git a/apps/autocal/tests/test_session_review.py b/apps/autocal/tests/test_session_review.py
index 266005a..7d09b55 100644
--- a/apps/autocal/tests/test_session_review.py
+++ b/apps/autocal/tests/test_session_review.py
@@ -110,6 +110,9 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog = SessionReviewDialog(session)
assert dialog.sites.count() == 2
assert dialog.table.rowCount() == 5
+ assert dialog.pages.currentIndex() == 0 and dialog.pages.count() == 2
+ assert len(dialog.trace_chart.axes()) == 2
+ assert all(len(series.attachedAxes()) == 2 for series in dialog.trace_chart.series())
assert len(dialog.chart.series()) == 4
assert len(dialog.uv_chart.series()) == 2
dialog.sites.setCurrentIndex(1)