feat(autocal): 完善标定记录检查流程与UV轮廓显示

补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
This commit is contained in:
ykcheungab
2026-10-11 20:15:04 +08:00
parent 432f45325f
commit d06501d7d5
17 changed files with 719 additions and 141 deletions
+53 -22
View File
@@ -488,6 +488,8 @@
"flow_missing": "No valid displacement for this frame" "flow_missing": "No valid displacement for this frame"
}, },
"overview": { "overview": {
"capture_in_progress": "In progress",
"saved": "Analysis saved; hover for its location.",
"open_session": "Open calibration session…", "open_session": "Open calibration session…",
"empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.", "empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.",
"title": "Calibration record overview", "title": "Calibration record overview",
@@ -496,36 +498,64 @@
"sensitivity": "Normal sensitivity", "sensitivity": "Normal sensitivity",
"import_plan": "Import planned-point CSV…", "import_plan": "Import planned-point CSV…",
"clear_plan": "Clear plan comparison", "clear_plan": "Clear plan comparison",
"load": "Load saved review…", "load": "Load saved analysis…",
"save": "Save new overview and review", "save": "Save analysis results",
"summary": "Acquired {collected}; planned {planned}; missing {missing}; incomplete attempts {incomplete}; pending review {pending}; reviewed {reviewed}; outside plan {extra}.", "summary": "Collected {collected} / planned {planned}; missing {missing}; with incomplete attempts {incomplete}; unchecked {pending}; checked {reviewed}; extra {extra}.",
"no_plan": "Without a plan, missing sites cannot be determined. Plan order is separate from acquisition site_id.", "no_plan": "Import a plan to identify missing sites.",
"uncollected": "Not acquired", "uncollected": "Not acquired",
"incomplete": "Has incomplete attempts", "incomplete": "Has incomplete attempts",
"pending": "Pending review", "pending": "Awaiting check",
"reviewed": "Reviewed", "reviewed": "Stable windows checked",
"confirmed": "Confirmed", "confirmed": "Checked",
"candidate": "Candidate", "candidate": "Unchecked",
"coverage_map": "Red: incomplete attempts; orange: pending; green: reviewed; grey: not acquired", "coverage_map": "Red: incomplete attempts; amber: unchecked; green: checked; grey: uncollected",
"sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N<br/>Grey: no confirmed fit", "sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N<br/>Grey: no confirmed fit",
"details": "Inspect signal / review windows…", "details": "Check hold windows…",
"trial_filter": "Displayed trial", "trial_filter": "Displayed trial",
"all_trials": "All trials", "all_trials": "All trials",
"site_fit": "Site loading fit (all eligible trials)", "site_fit": "Site loading fit (all eligible trials)",
"response_title": "Normal response: reference force → raw signal", "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.", "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 complete hold windows for this site/trial. Incomplete trials are excluded from fits; raw files are retained.", "no_windows": "No eligible complete windows; recorded signal and stop diagnostics remain available.",
"col_order": "Plan order", "col_order": "Plan order",
"col_site": "Acquired site", "col_site": "Acquired site",
"col_uv": "U / V (px)", "col_uv": "U / V (px)",
"col_xyz": "Sensor XYZ (mm)", "col_xyz": "Sensor XYZ (mm)",
"col_status": "Status", "col_status": "Analysis readiness",
"col_trials": "Complete / all trials", "col_trials": "Complete / all trials",
"col_windows": "Confirmed / complete windows", "col_windows": "Confirmed / complete windows",
"col_levels": "Confirmed loading levels (N)", "col_levels": "Confirmed loading levels (N)",
"col_sensitivity": "Sensitivity (raw/N) / basis", "col_sensitivity": "Sensitivity (raw/N) / basis",
"col_range": "Fit range (N)", "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": { "quality": {
"open": "Repeatability and validation…", "open": "Repeatability and validation…",
@@ -562,9 +592,9 @@
"col_range_status": "Range/status" "col_range_status": "Range/status"
}, },
"review": { "review": {
"load_selection": "Load saved review…", "load_selection": "Load saved analysis…",
"confirm_stable": "Reviewed as stable", "confirm_stable": "This interval was checked for stability",
"apply_window": "Apply window and recalculate", "apply_window": "Confirm interval and recompute",
"start_offset": "Start", "start_offset": "Start",
"end_offset": "End", "end_offset": "End",
"verified": "Stability confirmed by operator", "verified": "Stability confirmed by operator",
@@ -575,9 +605,9 @@
"trace_title": "Selected hold signal; vertical lines mark the analysis window", "trace_title": "Selected hold signal; vertical lines mark the analysis window",
"selection_unsaved": "Window applied; save the analysis to preserve this review.", "selection_unsaved": "Window applied; save the analysis to preserve this review.",
"open": "Review calibration data…", "open": "Review calibration data…",
"title": "Exploratory calibration review", "title": "Hold window inspection",
"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.", "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 new review", "save": "Save analysis results",
"no_sites": "No sites in this session", "no_sites": "No sites in this session",
"candidate": "Candidate window", "candidate": "Candidate window",
"insufficient_samples": "Insufficient valid samples", "insufficient_samples": "Insufficient valid samples",
@@ -590,7 +620,7 @@
"force_axis": "Manual reference force (N)", "force_axis": "Manual reference force (N)",
"chart_title": "Hold-window means: loading vs unloading", "chart_title": "Hold-window means: loading vs unloading",
"uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit", "uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit",
"issues": "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}", "saved": "Saved new review: {path}",
"col_trial": "Trial", "col_trial": "Trial",
"col_branch": "Phase", "col_branch": "Phase",
@@ -599,6 +629,7 @@
"col_mean": "Raw mean", "col_mean": "Raw mean",
"col_std": "Raw std", "col_std": "Raw std",
"col_duration": "Hold (s)", "col_duration": "Hold (s)",
"col_quality": "Quality" "col_quality": "Quality",
"col_drift": "Drift (raw/s)"
} }
} }
+53 -22
View File
@@ -488,6 +488,8 @@
"flow_missing": "本帧无有效位移信号" "flow_missing": "本帧无有效位移信号"
}, },
"overview": { "overview": {
"capture_in_progress": "采集中",
"saved": "分析结果已保存;保存位置见提示。",
"open_session": "打开标定会话…", "open_session": "打开标定会话…",
"empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。", "empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。",
"title": "标定记录总览", "title": "标定记录总览",
@@ -496,36 +498,64 @@
"sensitivity": "法向灵敏度", "sensitivity": "法向灵敏度",
"import_plan": "导入计划测点 CSV…", "import_plan": "导入计划测点 CSV…",
"clear_plan": "清除计划对照", "clear_plan": "清除计划对照",
"load": "载入已保存复核…", "load": "载入历史分析…",
"save": "另存总览与复核", "save": "保存分析结果",
"summary": "已采集 {collected} 点;计划 {planned} 点;未采 {missing};有未完成试验 {incomplete};待复核 {pending};已复核 {reviewed};计划外 {extra}。", "summary": "已采 {collected} 点/计划 {planned} 点;漏采 {missing};有未完成试验 {incomplete};待检查 {pending};已检查 {reviewed};计划外 {extra}。",
"no_plan": "未导入计划表,无法判断漏采点;序号仅用于计划对照,不作为采集 site_id。", "no_plan": "未导入计划,暂不能判断漏采点。",
"uncollected": "尚未采集", "uncollected": "尚未采集",
"incomplete": "有未完成试验", "incomplete": "有未完成试验",
"pending": "待复核", "pending": "待检查",
"reviewed": "已复核", "reviewed": "稳定段已检查",
"confirmed": "已确认", "confirmed": "已检查",
"candidate": "候选", "candidate": "待检查",
"coverage_map": "红:有未完成试验;橙:待复核;绿:已复核;灰:未采", "coverage_map": "红:有未完成试验;橙:待检查;绿:已检查;灰:未采",
"sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N<br/>灰:无已确认拟合", "sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N<br/>灰:无已确认拟合",
"details": "查看信号/复核窗口…", "details": "检查保持段…",
"trial_filter": "显示试验", "trial_filter": "显示试验",
"all_trials": "全部 trial", "all_trials": "全部 trial",
"site_fit": "测点加载拟合(全部符合条件的 trial)", "site_fit": "测点加载拟合(全部符合条件的 trial)",
"response_title": "法向响应:参考力 → 原始信号", "response_title": "法向响应:参考力 → 原始信号",
"selected_fit": "本测点初步灵敏度 {sensitivity} raw/N({basis});拟合范围和 trial 数见表格。原始信号与实际参考力仍须实机验证。", "selected_fit": "灵敏度 {sensitivity} raw/N · {basis}。拟合按本点全部合格加载窗口计算;范围及试验数见测点提示。",
"no_windows": "所选点/trial 无完整保持窗口;未完成试验不参与拟合,原始文件仍保留。", "no_windows": "所选试验没有可分析的完整窗口;可查看已有原始信号和结束原因。",
"col_order": "计划序号", "col_order": "计划序号",
"col_site": "采集测点", "col_site": "采集测点",
"col_uv": "U / V (px)", "col_uv": "U / V (px)",
"col_xyz": "传感器 XYZ (mm)", "col_xyz": "传感器 XYZ (mm)",
"col_status": "状态", "col_status": "分析准备状态",
"col_trials": "完成/全部 trial", "col_trials": "完成/全部 trial",
"col_windows": "确认/完整窗口", "col_windows": "确认/完整窗口",
"col_levels": "已确认加载档位 (N)", "col_levels": "已确认加载档位 (N)",
"col_sensitivity": "灵敏度 (raw/N)/依据", "col_sensitivity": "灵敏度 (raw/N)/依据",
"col_range": "拟合范围 (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": { "quality": {
"open": "重复性与验证…", "open": "重复性与验证…",
@@ -562,9 +592,9 @@
"col_range_status": "范围/状态" "col_range_status": "范围/状态"
}, },
"review": { "review": {
"load_selection": "载入已保存复核…", "load_selection": "载入历史分析…",
"confirm_stable": "已检查此段稳定", "confirm_stable": "此时间段已检查稳定",
"apply_window": "应用窗口并重算", "apply_window": "确认选中时间段并重算",
"start_offset": "起点", "start_offset": "起点",
"end_offset": "终点", "end_offset": "终点",
"verified": "人工已确认稳定", "verified": "人工已确认稳定",
@@ -575,9 +605,9 @@
"trace_title": "选中保持段原始信号;竖线为分析窗口边界", "trace_title": "选中保持段原始信号;竖线为分析窗口边界",
"selection_unsaved": "窗口已应用;请另存分析结果以保留复核。", "selection_unsaved": "窗口已应用;请另存分析结果以保留复核。",
"open": "查看标定数据…", "open": "查看标定数据…",
"title": "标定数据初步分析", "title": "保持段检查",
"notice": "逐段查看原始信号,调整起止时间并确认稳定后应用。起止时间相对本段保持开始;人工复核不证明参考力准确或同步。视频重放仅供调试;拟合不是正式标定系数。", "notice": "选择保持段 → 检查曲线、标准差及漂移 → 调整起止时间 → 确认稳定并重算 → 保存。人工确认不是精度验收。",
"save": "另存分析结果", "save": "保存分析结果",
"no_sites": "会话没有测点", "no_sites": "会话没有测点",
"candidate": "候选窗口", "candidate": "候选窗口",
"insufficient_samples": "有效样本不足", "insufficient_samples": "有效样本不足",
@@ -590,7 +620,7 @@
"force_axis": "人工参考力 (N)", "force_axis": "人工参考力 (N)",
"chart_title": "各保持窗口均值:加载/卸载对照", "chart_title": "各保持窗口均值:加载/卸载对照",
"uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合", "uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合",
"issues": "已跳过 {count} 个未完成 trial;完整保持窗口仍须复核稳定性。", "issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。",
"saved": "已另存:{path}", "saved": "已另存:{path}",
"col_trial": "试验", "col_trial": "试验",
"col_branch": "阶段", "col_branch": "阶段",
@@ -599,6 +629,7 @@
"col_mean": "原始均值", "col_mean": "原始均值",
"col_std": "原始标准差", "col_std": "原始标准差",
"col_duration": "保持 (s)", "col_duration": "保持 (s)",
"col_quality": "质量说明" "col_quality": "质量说明",
"col_drift": "漂移 (raw/s)"
} }
} }
+12 -10
View File
@@ -1,6 +1,6 @@
# Autocal 标定操作与数据格式草稿 # Autocal 标定操作与数据格式草稿
状态:2026-10-11 修订,阶段一仅针对 finger,待实机验收。已接通手动加载/卸载、多测点采集、记录总览和窗口复核;FT 同步及正式灵敏度结果仍待实现。 状态:2026-10-11 修订,仅 finger,待实机验收。手动采集、记录总览及稳定段检查已接通;FT 同步及正式系数待实现。
## 范围与坐标 ## 范围与坐标
@@ -52,7 +52,7 @@
## 目录与文件 ## 目录与文件
本场目录名为 `<产品型号>_<SN>_<UTC时间>_<唯一标识>`,完整身份保存在 manifest。每个 trial 是一个测点的一次完整循环,档位用编号及时间窗口区分,不按力值拆原始视频目录。 目录名为 `<产品型号>_<SN>_<UTC时间>_<唯一标识>`,身份保存在 manifest。一个 trial 对应同点一次循环;档位用编号和时间区间区分,不拆视频。
```text ```text
finger_F001_<时间>_<标识>/ 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 是独立的整数编号 | | 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;不等于来源视频 | | `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 |
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 | | `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
@@ -108,15 +108,17 @@ finger_F001_<时间>_<标识>/
主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。 主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。
“标定记录”页打开会话目录;采集页“查看标定数据…”也跳转至此。点击 UV 点或表格,联动响应及 trial 筛选,再“查看信号/复核窗口…”。 “标定记录”页打开会话,核对型号/SN、时间、来源和采集状态。选点及单次试验,先看时间曲线及诊断,再“检查保持段…”,最后“保存分析结果”。采集页“查看标定数据…”也跳转至此。
| 总览 | 规则 | | 总览 | 规则 |
|---|---| |---|---|
| 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 | | 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 |
| 采集状态 | 未采、有未完成试验、待复核、已复核;完成次数及已确认窗口数分别列出,不把未完成 trial 用于拟合 | | 状态 | 采集完整性与稳定段检查分别展示;未完成 trial 可查看曲线、结束原因及缺失文件,不参与拟合 |
| UV 轮廓 | 新采集的 uv_outline 保存映射身份及传感面/彩色 patch 外轮廓;旧记录仅恢复映射验证一致的几何轮廓,缺失或不匹配时提示;显示保持像素等比例 |
| 时间曲线 | FN_raw 及人工保持参考力;显示有效样本、无效/未关联数、录像丢帧、最大帧间隔;各 trial 独立时间原点,不代表 FT 同步 |
| 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 | | 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 |
| 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 | | 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 |
| 保存恢复 | “另存总览与复核”保存计划快照及窗口;载入 review.json 同时恢复总览,无 overview.json 时仅恢复复核 | | 保存恢复 | 保存计划及窗口快照;载入 review.json 恢复总览,无 overview.json 时仅恢复窗口;未保存修改有提示 |
拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。 拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。
@@ -124,12 +126,12 @@ finger_F001_<时间>_<标识>/
实时 UV 图有真实背景、位移箭头、深度三个开关;关闭背景显示纯色画布。深度按当前帧有效 UV 有限值范围自适应,显示范围;跨帧同色不代表同值,算法单位不是毫米。缺失或过期字段不绘制并提示。显示不改变录像及坐标。 实时 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)。 跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。
@@ -51,6 +51,7 @@ class CalibrationSession:
"sensor_sn": metadata["sensor_sn"], "sensor_sn": metadata["sensor_sn"],
"config_sha256": metadata["config_sha256"], "config_sha256": metadata["config_sha256"],
"uv_definition": metadata["uv_definition"], "uv_definition": metadata["uv_definition"],
"uv_outline": metadata.get("uv_outline"),
} }
def add_trial(self, metadata: dict) -> dict: def add_trial(self, metadata: dict) -> dict:
@@ -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
@@ -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"
@@ -200,6 +200,10 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"trace": trace, "trace": trace,
"raw_mean": float(raw.mean()) if raw.size else None, "raw_mean": float(raw.mean()) if raw.size else None,
"raw_std": float(raw.std()) 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, "stability_verified": verified,
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]), "reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples", "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, "status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs, "selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"], "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"),
} }
+5
View File
@@ -84,6 +84,7 @@ class ManualTrial:
source: dict, source: dict,
config_ref: str, config_ref: str,
fps: float, fps: float,
uv_outline: dict | None = None,
) -> Path: ) -> Path:
if self.status in {"recording", "closing"}: if self.status in {"recording", "closing"}:
raise RuntimeError("a trial is already active") raise RuntimeError("a trial is already active")
@@ -141,7 +142,11 @@ class ManualTrial:
"reference_alignment_possible": self.reference_alignment_possible, "reference_alignment_possible": self.reference_alignment_possible,
"reference_source": "manual_operator_confirmation", "reference_source": "manual_operator_confirmation",
"reference_file": "reference.csv", "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( recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True) RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
) )
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
) )
from ..sensor.uv_flat import site_at, uv_origin from ..sensor.uv_flat import site_at, uv_origin
from ..sensor.uv_outline import outline_snapshot
from ..trial import ManualTrial, load_path from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView from .sensor_panel import FlatSurfaceView
from .live_force_view import LiveForceView from .live_force_view import LiveForceView
@@ -199,6 +200,7 @@ class CalibrationPanel(QWidget):
if self.trial.session is not None: if self.trial.session is not None:
raise RuntimeError(t("calibration.close_before_mapping")) raise RuntimeError(t("calibration.close_before_mapping"))
self._flat = flat self._flat = flat
self._uv_outline = outline_snapshot(flat)
self._bundle_path = bundle_path self._bundle_path = bundle_path
self.flat_view.set_flat(flat) self.flat_view.set_flat(flat)
origin_x, origin_y = uv_origin(flat) origin_x, origin_y = uv_origin(flat)
@@ -275,6 +277,7 @@ class CalibrationPanel(QWidget):
source=source, source=source,
config_ref=self.source_panel.config_ref.text().strip(), config_ref=self.source_panel.config_ref.text().strip(),
fps=mode.fps, fps=mode.fps,
uv_outline=self._uv_outline,
) )
self.views.setCurrentWidget(self.source_panel) self.views.setCurrentWidget(self.source_panel)
self.result_label.setText( self.result_label.setText(
@@ -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)
@@ -1,19 +1,29 @@
"""Recorded-site coverage and normal response, with optional operator CSV plan.""" """Recorded-site coverage and normal response, with optional operator CSV plan."""
from pathlib import Path 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 orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis 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.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel, 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 ..record_overview import load_overview, overview_rows, read_plan, save_overview
from ..session_review import review_session 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 .session_review_dialog import SessionReviewDialog
from .chart_helpers import add_value_axes, clear_chart
class RecordOverviewPage(QWidget): class RecordOverviewPage(QWidget):
@@ -27,12 +37,20 @@ class RecordOverviewPage(QWidget):
self.open_button = QPushButton(self) self.open_button = QPushButton(self)
self.open_button.setObjectName("openCalibrationRecords") self.open_button.setObjectName("openCalibrationRecords")
self.open_button.clicked.connect(self._open) 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 = QLabel(self)
self.path_label.setWordWrap(True) self.path_label.setWordWrap(True)
self.message = QLabel(self) self.message = QLabel(self)
self.message.setWordWrap(True) self.message.setWordWrap(True)
self.content_layout.addWidget(self.open_button) self.message.hide()
self.content_layout.addWidget(self.path_label) 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.addWidget(self.message)
self.content_layout.addStretch(1) self.content_layout.addStretch(1)
self.retranslate() self.retranslate()
@@ -52,19 +70,23 @@ class RecordOverviewPage(QWidget):
overview = RecordOverviewDialog(directory, self) overview = RecordOverviewDialog(directory, self)
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
return return
overview.setWindowFlags(Qt.WindowType.Widget) overview.setWindowFlags(Qt.WindowType.Widget)
if self.overview is not None: if self.overview is not None:
self.content_layout.removeWidget(self.overview) self.viewer.takeWidget()
self.overview.hide() self.overview.hide()
self.overview.deleteLater() self.overview.deleteLater()
else: else:
self.content_layout.takeAt(self.content_layout.count() - 1) self.content_layout.takeAt(self.content_layout.count() - 1)
self.content_layout.addWidget(self.viewer, 1)
self.overview = overview self.overview = overview
self.directory = directory 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.message.clear()
self.content_layout.addWidget(overview, 1) self.message.hide()
self.viewer.setWidget(overview)
overview.show() overview.show()
@@ -77,38 +99,79 @@ class RecordOverviewDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.directory = directory self.directory = directory
self.review = review_session(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.plan = None
self.rows = [] self.rows = []
self.setWindowTitle(t("overview.title")) self.setWindowTitle(t("overview.title"))
self.resize(1240, 850) 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) 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.setWordWrap(True)
self.notice.setToolTip(t("overview.notice"))
layout.addWidget(self.notice) layout.addWidget(self.notice)
toolbar = QHBoxLayout() toolbar = QHBoxLayout()
self.map_mode = QComboBox(self) self.map_mode = QComboBox(self)
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")]) self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
toolbar.addWidget(self.map_mode) toolbar.addWidget(self.map_mode)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), self.map_mode.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
("load", self._load), ("save", self._save)): self.tools = QToolButton(self)
button = QPushButton(t("overview." + name), self) self.tools.setText(t("overview.tools"))
button.setObjectName("overview_" + name) self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
button.clicked.connect(slot) menu = QMenu(self.tools)
toolbar.addWidget(button) 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) layout.addLayout(toolbar)
self.summary = QLabel(self) self.summary = QLabel(self)
self.summary.setWordWrap(True) self.summary.setWordWrap(True)
layout.addWidget(self.summary) layout.addWidget(self.summary)
charts = QHBoxLayout() charts = QHBoxLayout()
self.uv_chart, self.response_chart = QChart(), QChart() 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) 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) view.setRenderHint(QPainter.RenderHint.Antialiasing)
charts.addWidget(self.uv_view, 1) view.chart().setMinimumSize(0, 0)
charts.addWidget(self.response_view, 2) 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) layout.addLayout(charts, 2)
self.table = QTableWidget(0, 11, self) self.table = QTableWidget(0, 11, self)
self.table.setObjectName("overviewSites") self.table.setObjectName("overviewSites")
self.table.setMinimumHeight(125)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) 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")]) "order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True) self.table.horizontalHeader().setStretchLastSection(True)
for column in (0, 3, 9, 10):
self.table.setColumnHidden(column, True)
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
selection = QHBoxLayout() selection = QHBoxLayout()
self.trials = QComboBox(self) self.trials = QComboBox(self)
self.trials.setObjectName("overviewTrial") self.trials.setObjectName("overviewTrial")
self.details_button = QPushButton(t("overview.details"), self) self.details_button = QPushButton(t("overview.details"), self)
selection.addWidget(QLabel(t("overview.trial_filter"), 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) 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) layout.addLayout(selection)
self.selected_summary = QLabel(self) self.selected_summary = QLabel(self)
self.selected_summary.setWordWrap(True) self.selected_summary.setWordWrap(True)
layout.addWidget(self.selected_summary) 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 = QLabel(self)
self.message.setWordWrap(True) self.message.setWordWrap(True)
layout.addWidget(self.message) 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.table.itemSelectionChanged.connect(self._select_site)
self.map_mode.currentIndexChanged.connect(self._show_uv) self.map_mode.currentIndexChanged.connect(self._show_uv)
self.trials.currentIndexChanged.connect(self._show_response) self.trials.currentIndexChanged.connect(self._show_response)
self.trials.currentIndexChanged.connect(self._show_timeline)
self.details_button.clicked.connect(self._details) self.details_button.clicked.connect(self._details)
self._refresh() self._refresh()
def _refresh(self): 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() selected = self.table.currentRow()
self.rows = overview_rows(self.review, self.plan) self.rows = overview_rows(self.review, self.plan)
self.table.blockSignals(True) self.table.blockSignals(True)
@@ -152,25 +241,31 @@ class RecordOverviewDialog(QDialog):
f"{row['confirmed_windows']}/{row['windows']}", 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["confirmed_levels_n"]) or "—", sensitivity,
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])] "–".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): for column, value in enumerate(values):
self.table.setItem(index, column, QTableWidgetItem(value)) 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) self.table.blockSignals(False)
collected = sum(row["trials"] > 0 for row in self.rows) collected = sum(row["trials"] > 0 for row in self.rows)
self.summary.setText(t("overview.summary").format( self.summary.setText(t("overview.summary").format(
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—", 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), incomplete=sum(r["status"] == "incomplete" for r in self.rows),
pending=sum(r["status"] == "pending" 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), 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 "—", 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 "")) ) + ("\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: if self.rows:
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1)) self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
self._select_site() self._select_site()
def _select_site(self): def _select_site(self):
index = self.table.currentRow() index = self.table.currentRow()
previous = self.trials.currentData()
self.trials.blockSignals(True) self.trials.blockSignals(True)
self.trials.clear() self.trials.clear()
self.trials.addItem(t("overview.all_trials"), None) self.trials.addItem(t("overview.all_trials"), None)
@@ -179,33 +274,22 @@ class RecordOverviewDialog(QDialog):
for trial in self.review["trials"]: for trial in self.review["trials"]:
if site_id is not None and trial.get("site_id") == site_id: 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"]) 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.trials.blockSignals(False)
self._show_uv() self._show_uv()
self._show_response() self._show_response()
self._show_timeline()
@staticmethod _clear_chart = staticmethod(clear_chart)
def _clear_chart(chart): _axes = staticmethod(add_value_axes)
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)
def _show_uv(self): 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) self._clear_chart(self.uv_chart)
sensitivity = self.map_mode.currentIndex() == 1 sensitivity = self.map_mode.currentIndex() == 1
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]] 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.append(*row["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target)) series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
self.uv_chart.addSeries(series) 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) self._axes(self.uv_chart)
definition = self.review["uv_definition"] bounds = [tuple(row["uv_xy_px"]) for row in self.rows] + [(0, 0)]
origin = definition.get("origin_raster_xy_px") if self.outline:
shape = definition.get("raster_size_hw") 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): for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)") axis.setTitleText("U (px)")
if origin and shape: axis.setLabelFormat("%.0f")
axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5)
for axis in self.uv_chart.axes(Qt.Orientation.Vertical): for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)") axis.setTitleText("V (px)")
if origin and shape: axis.setLabelFormat("%.0f")
axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5)
self.uv_chart.legend().hide() self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high) self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
if sensitivity else t("overview.coverage_map")) 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): def _show_response(self):
self._clear_chart(self.response_chart) 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) site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
trial_id = self.trials.currentData() 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 [] 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 trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")): for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
for confirmed in (True, False): 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 "—", basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
) if windows else t("overview.no_windows")) ) 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): def _import_plan(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)") path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
if path: if path:
@@ -293,17 +486,26 @@ class RecordOverviewDialog(QDialog):
plan = read_plan(Path(path), self.review["uv_definition"]) plan = read_plan(Path(path), self.review["uv_definition"])
overview_rows(self.review, plan) overview_rows(self.review, plan)
self.plan = plan self.plan = plan
self._dirty = True
self._refresh() self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
def _clear_plan(self): def _clear_plan(self):
self.plan = None self.plan = None
self._dirty = True
self._refresh() self._refresh()
def _details(self): def _details(self):
row = self.rows[self.table.currentRow()] row = self.rows[self.table.currentRow()]
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"]) 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 = SessionReviewDialog(self.directory, self, review=self.review)
dialog.sites.setCurrentIndex(index) dialog.sites.setCurrentIndex(index)
trial = self.trials.currentData() trial = self.trials.currentData()
@@ -314,14 +516,20 @@ class RecordOverviewDialog(QDialog):
dialog.table.selectRow(match) dialog.table.selectRow(match)
dialog.exec() dialog.exec()
self.review = dialog.review self.review = dialog.review
self._dirty = self._dirty or before != self.review["selections"]
self._refresh() self._refresh()
def _save(self): def _save(self):
try: try:
path = save_overview(self.review, self.plan) 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: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
def _load(self): def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
@@ -329,6 +537,8 @@ class RecordOverviewDialog(QDialog):
try: try:
review, plan = load_overview(self.directory, Path(path)) review, plan = load_overview(self.directory, Path(path))
self.review, self.plan = review, plan self.review, self.plan = review, plan
self._dirty = False
self._refresh() self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
@@ -10,10 +10,11 @@ from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, 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 ..session_review import review_session, save_review, load_review
from .chart_helpers import add_value_axes, clear_chart
class SessionReviewDialog(QDialog): class SessionReviewDialog(QDialog):
@@ -23,6 +24,10 @@ class SessionReviewDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(t("review.title")) self.setWindowTitle(t("review.title"))
self.resize(1080, 760) 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.review = review if review is not None else review_session(directory)
self.directory = directory self.directory = directory
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -41,6 +46,12 @@ class SessionReviewDialog(QDialog):
row.addWidget(self.save_button) row.addWidget(self.save_button)
row.addWidget(self.load_button) row.addWidget(self.load_button)
layout.addLayout(row) 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 = QLabel(self)
self.summary.setWordWrap(True) self.summary.setWordWrap(True)
summary_row = QHBoxLayout() summary_row = QHBoxLayout()
@@ -49,7 +60,7 @@ class SessionReviewDialog(QDialog):
self.quality_button.setObjectName("reviewQuality") self.quality_button.setObjectName("reviewQuality")
self.quality_button.clicked.connect(self._quality) self.quality_button.clicked.connect(self._quality)
summary_row.addWidget(self.quality_button) summary_row.addWidget(self.quality_button)
layout.addLayout(summary_row) response_layout.addLayout(summary_row)
self.chart = QChart() self.chart = QChart()
self.chart_view = QChartView(self.chart, self) self.chart_view = QChartView(self.chart, self)
self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing) self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -59,15 +70,15 @@ class SessionReviewDialog(QDialog):
charts = QHBoxLayout() charts = QHBoxLayout()
charts.addWidget(self.uv_view, 1) charts.addWidget(self.uv_view, 1)
charts.addWidget(self.chart_view, 2) charts.addWidget(self.chart_view, 2)
layout.addLayout(charts, 1) response_layout.addLayout(charts, 1)
self.table = QTableWidget(0, 8, self) self.table = QTableWidget(0, 9, self)
self.table.setObjectName("reviewWindows") self.table.setObjectName("reviewWindows")
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in ( 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().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True) self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 1) hold_layout.addWidget(self.table, 1)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
editor = QHBoxLayout() editor = QHBoxLayout()
@@ -84,11 +95,11 @@ class SessionReviewDialog(QDialog):
editor.addWidget(self.end_s) editor.addWidget(self.end_s)
editor.addWidget(self.verified) editor.addWidget(self.verified)
editor.addWidget(self.apply_button) editor.addWidget(self.apply_button)
layout.addLayout(editor) hold_layout.addLayout(editor)
self.trace_chart = QChart() self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self) self.trace_view = QChartView(self.trace_chart, self)
self.trace_view.setMinimumHeight(130) self.trace_view.setMinimumHeight(240)
layout.addWidget(self.trace_view, 1) hold_layout.addWidget(self.trace_view, 2)
self.message = QLabel(self) self.message = QLabel(self)
self.message.setWordWrap(True) self.message.setWordWrap(True)
layout.addWidget(self.message) layout.addWidget(self.message)
@@ -102,9 +113,7 @@ class SessionReviewDialog(QDialog):
def _show_site(self) -> None: def _show_site(self) -> None:
index = self.sites.currentIndex() index = self.sites.currentIndex()
self.quality_button.setEnabled(index >= 0) self.quality_button.setEnabled(index >= 0)
self.chart.removeAllSeries() clear_chart(self.chart)
for axis in self.chart.axes():
self.chart.removeAxis(axis)
if index < 0: if index < 0:
self.summary.setText(t("review.no_sites")) self.summary.setText(t("review.no_sites"))
return return
@@ -112,14 +121,14 @@ class SessionReviewDialog(QDialog):
windows = site["windows"] windows = site["windows"]
self.table.setRowCount(len(windows)) self.table.setRowCount(len(windows))
for row, window in enumerate(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"]: if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay") quality += " · " + t("review.replay")
quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified")
values = [window["trial_id"], t("calibration." + window["branch"]), values = [window["trial_id"], t("calibration." + window["branch"]),
f"{window['reference_n']:g}", str(window["samples"]), f"{window['reference_n']:g}", str(window["samples"]),
self._number(window["raw_mean"]), self._number(window["raw_std"]), 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): for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value)) self.table.setItem(row, column, QTableWidgetItem(value))
fit = site["confirmed_fit"] or site["fit"] fit = site["confirmed_fit"] or site["fit"]
@@ -144,12 +153,11 @@ class SessionReviewDialog(QDialog):
for value in (min(raw), max(raw)): for value in (min(raw), max(raw)):
line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"]) line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"])
self.chart.addSeries(line) self.chart.addSeries(line)
self.chart.createDefaultAxes() add_value_axes(self.chart)
for axis in self.chart.axes(Qt.Orientation.Horizontal): for axis in self.chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("review.raw_axis")) axis.setTitleText(t("review.raw_axis"))
for axis in self.chart.axes(Qt.Orientation.Vertical): for axis in self.chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.force_axis")) axis.setTitleText(t("review.force_axis"))
self._pad_axes(self.chart)
self.chart.setTitle(t("review.chart_title")) self.chart.setTitle(t("review.chart_title"))
self._show_uv(index) self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
@@ -172,9 +180,7 @@ class SessionReviewDialog(QDialog):
def _show_window(self): def _show_window(self):
window = self._current_window() window = self._current_window()
self.trace_chart.removeAllSeries() clear_chart(self.trace_chart)
for axis in self.trace_chart.axes():
self.trace_chart.removeAxis(axis)
self.apply_button.setEnabled(window is not None) self.apply_button.setEnabled(window is not None)
if window is None: if window is None:
return return
@@ -199,7 +205,7 @@ class SessionReviewDialog(QDialog):
line.append((stamp - origin) / 1e9, low - margin) line.append((stamp - origin) / 1e9, low - margin)
line.append((stamp - origin) / 1e9, high + margin) line.append((stamp - origin) / 1e9, high + margin)
self.trace_chart.addSeries(line) 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): for axis in self.trace_chart.axes(Qt.Orientation.Horizontal):
axis.setRange(0, duration) axis.setRange(0, duration)
axis.setTitleText(t("review.hold_time_axis")) axis.setTitleText(t("review.hold_time_axis"))
@@ -243,9 +249,7 @@ class SessionReviewDialog(QDialog):
self.message.setText(str(exc)) self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None: def _show_uv(self, selected: int) -> None:
self.uv_chart.removeAllSeries() clear_chart(self.uv_chart)
for axis in self.uv_chart.axes():
self.uv_chart.removeAxis(axis)
fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]] 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] values = [fit["sensitivity_raw_per_n"] for fit in fits if fit]
low, high = (min(values), max(values)) if values else (0, 0) 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.append(*site["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target)) series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target))
self.uv_chart.addSeries(series) 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): for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)") axis.setTitleText("U (px)")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical): for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)") axis.setTitleText("V (px)")
self._pad_axes(self.uv_chart)
self.uv_chart.legend().hide() self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high)) 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 @staticmethod
def _number(value) -> str: def _number(value) -> str:
return "—" if value is None else f"{value:.6g}" return "—" if value is None else f"{value:.6g}"
@@ -61,6 +61,7 @@ _SCOPED_ENUMS = {
"TextElideMode", "TextElideMode",
"TextFlag", "TextFlag",
"AspectRatioMode", "AspectRatioMode",
"WindowType",
), ),
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"), "QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
"QSizePolicy": ("Policy",), "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(): def test_the_controller_interface_is_qt_free():
"""The controller and its guards stay importable from a headless process. """The controller and its guards stay importable from a headless process.
+24
View File
@@ -111,6 +111,27 @@ def test_uv_coordinates_are_centered_and_masked(flat):
site_at(flat, -3, 0) 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): def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock):
worker = Worker() worker = Worker()
trial = ManualTrial(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.levels.setText("5, 10")
panel.start_button.click() panel.start_button.click()
assert panel.trial.status == "recording" 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() worker.emit()
assert panel.views.currentWidget() is panel.source_panel assert panel.views.currentWidget() is panel.source_panel
assert not panel.sensor_sn.isEnabled() assert not panel.sensor_sn.isEnabled()
@@ -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")
+48 -4
View File
@@ -6,6 +6,7 @@ from copy import deepcopy
import pytest import pytest
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
from PySide6.QtGui import QAction
from test_session_review import repeated_session as repeated_session, session as session from test_session_review import repeated_session as repeated_session, session as session
from orisys_autocal.calibration_session import write_json 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], definition = {"mapping_sha256": "test-map", "origin_raster_xy_px": [5, 8],
"raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"} "raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"}
manifest["uv_definition"] = definition 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"]): for index, site in enumerate(manifest["sites"]):
site["position_sensor_mm"] = [index + 1., 2., 3.] site["position_sensor_mm"] = [index + 1., 2., 3.]
for trial in manifest["trials"]: for trial in manifest["trials"]:
@@ -27,6 +29,7 @@ def overview_session(repeated_session):
path = root / trial["directory"] / "session.json" path = root / trial["directory"] / "session.json"
metadata = json.loads(path.read_text(encoding="utf-8")) metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["uv_definition"] = definition metadata["uv_definition"] = definition
metadata["input_source"] = {"kind": "video", "value": "test.mp4"}
write_json(path, metadata) write_json(path, metadata)
trial["plan"] = {"steps": list(range(5))} trial["plan"] = {"steps": list(range(5))}
manifest["sites"].append({"site_id": "site-003", "uv_xy_px": [3, 2], "position_sensor_mm": [3., 2., 3.]}) 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.review = review_session(root, selections=selections)
dialog._refresh() dialog._refresh()
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), "")) 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.rowCount() == 4
assert dialog.table.item(0, 1).text() == "site-002" assert dialog.table.item(0, 1).text() == "site-002"
dialog.uv_chart.series()[1].clicked.emit(dialog.uv_chart.series()[1].at(0)) 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.table.selectRow(3)
dialog.trials.setCurrentIndex(1) dialog.trials.setCurrentIndex(1)
assert not dialog.response_chart.series() 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.table.selectRow(2)
dialog.map_mode.setCurrentIndex(1) dialog.map_mode.setCurrentIndex(1)
assert len(dialog.uv_chart.series()) == 4 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")) assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click() dialog.findChild(QPushButton, "overview_save").click()
saved = next(root.glob("derived/*/review.json")) 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 assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), "")) 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 assert dialog.table.rowCount() == 4
dialog.close() 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) QTest.keyClick(page.overview, Qt.Key.Key_Escape)
assert page.overview.isVisible() assert page.overview.isVisible()
assert window.grab().save(str(tmp_path / "records-tab.png")) 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() 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()
@@ -110,6 +110,9 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog = SessionReviewDialog(session) dialog = SessionReviewDialog(session)
assert dialog.sites.count() == 2 assert dialog.sites.count() == 2
assert dialog.table.rowCount() == 5 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.chart.series()) == 4
assert len(dialog.uv_chart.series()) == 2 assert len(dialog.uv_chart.series()) == 2
dialog.sites.setCurrentIndex(1) dialog.sites.setCurrentIndex(1)